@sabaiway/agent-workflow-kit 3.0.0 → 3.1.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/CHANGELOG.md +49 -1
- package/README.md +1 -0
- package/SKILL.md +5 -1
- package/bin/install.mjs +10 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +20 -7
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +76 -18
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +20 -7
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +65 -17
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/recommendations.md +1 -0
- package/references/modes/worktrees.md +120 -0
- package/references/scripts/archive-decisions.mjs +2 -2
- package/references/scripts/archive-decisions.test.mjs +5 -5
- package/references/scripts/check-docs-size-cli.test.mjs +41 -0
- package/references/scripts/check-docs-size.mjs +46 -29
- package/tools/autonomy-doctor.mjs +1 -1
- package/tools/changed-surface.mjs +8 -8
- package/tools/commands.mjs +9 -0
- package/tools/grounding.mjs +13 -13
- package/tools/inject-methodology.mjs +71 -40
- package/tools/migrate-adr-store.mjs +3 -3
- package/tools/procedures.mjs +1 -1
- package/tools/recipes.mjs +2 -2
- package/tools/recommendations.mjs +34 -7
- package/tools/release-scan.mjs +9 -2
- package/tools/review-state.mjs +3 -3
- package/tools/sandbox-masks.mjs +13 -13
- package/tools/set-recipe.mjs +1 -1
- package/tools/velocity-profile.mjs +7 -7
- package/tools/worktrees.mjs +2292 -0
package/tools/recipes.mjs
CHANGED
|
@@ -460,7 +460,7 @@ export const composeActiveRecipeLine = ({ config, source } = {}, detection, auto
|
|
|
460
460
|
const rec = recommendRecipe(detection);
|
|
461
461
|
const origin = source === 'none' || config == null ? 'no config file — computed defaults apply' : `from ${source}`;
|
|
462
462
|
// A MALFORMED policy must surface LOUDLY on this paste surface too — silently rendering cells
|
|
463
|
-
// without levels would hide the required STOP signal (
|
|
463
|
+
// without levels would hide the required STOP signal (Segment B). One line always.
|
|
464
464
|
const malformed = autonomy?.error
|
|
465
465
|
? ` · autonomy: MALFORMED policy — ${String(autonomy.error).replace(/[\s]+/g, ' ').trim()}`
|
|
466
466
|
: '';
|
|
@@ -485,7 +485,7 @@ export const buildReport = (detection, settings = null, autonomy = null, posture
|
|
|
485
485
|
recommendation,
|
|
486
486
|
plans: RECIPES.map((r) => planRecipe(r.id, detection)),
|
|
487
487
|
// The SAME autonomy facts the --status-line surface renders — the --json envelope must never
|
|
488
|
-
// expose a stale machine-composed status line (
|
|
488
|
+
// expose a stale machine-composed status line (Segment B).
|
|
489
489
|
statusLine: composeStatusLine(detection, recommendation, settings, autonomy, posture),
|
|
490
490
|
};
|
|
491
491
|
};
|
|
@@ -59,6 +59,7 @@ import { resolveGitHooksPath } from './commit-guard.mjs';
|
|
|
59
59
|
import { loadConfig } from './orchestration-config.mjs';
|
|
60
60
|
import { DEFAULT_BUNDLE_ROOT, SETTINGS_FILENAME, settingsPath, parseSettings, duplicateKeys } from './bridge-settings-read.mjs';
|
|
61
61
|
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
62
|
+
import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
|
|
62
63
|
|
|
63
64
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
64
65
|
const toolPath = (rel) => join(HERE, rel);
|
|
@@ -108,6 +109,7 @@ export const SEVERITIES = Object.freeze({
|
|
|
108
109
|
'agy-adddir': SEVERITY_OPTIONAL,
|
|
109
110
|
'agy-adddir.invalid-env': SEVERITY_ATTENTION,
|
|
110
111
|
'sandbox-lane': SEVERITY_OPTIONAL,
|
|
112
|
+
'worktrees-dir': SEVERITY_OPTIONAL,
|
|
111
113
|
});
|
|
112
114
|
// The per-item render tags (frozen presentation data, same language contract as the templates).
|
|
113
115
|
export const SEVERITY_LABELS = Object.freeze({
|
|
@@ -165,6 +167,7 @@ export const WHATS = Object.freeze({
|
|
|
165
167
|
'agy-adddir': 'agy-review is placed but AGY_REVIEW_ALLOW_ADDDIR is not set ({file}) — an oversized code review refuses instead of offloading',
|
|
166
168
|
'agy-adddir.invalid-env': 'AGY_REVIEW_ALLOW_ADDDIR is set to an INVALID value ({value}) — refuse-mode applies and the settings file is shadowed while it is set',
|
|
167
169
|
'sandbox-lane': 'the wired review wrappers declare a session-sandbox recipe (egress hosts + writable state dirs) not yet acknowledged for this project',
|
|
170
|
+
'worktrees-dir': 'write access to the worktrees parent dir {dir} is not confirmed — provision may still stop',
|
|
168
171
|
});
|
|
169
172
|
|
|
170
173
|
// ── the shape contract (D2): registry strings AND composed items stay one line under the cap ────
|
|
@@ -215,6 +218,7 @@ export const BENEFITS = Object.freeze({
|
|
|
215
218
|
'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
|
|
216
219
|
'agy-adddir': 'large reviews — an oversized agy code review offloads to a staging dir instead of refusing',
|
|
217
220
|
'sandbox-lane': 'discoverability — the manifest-declared observed sandbox recipe for bridge runs surfaces itself instead of waiting to be asked',
|
|
221
|
+
'worktrees-dir': 'parallel features — the host-specific write allowance or terminal fallback is surfaced before provision',
|
|
218
222
|
});
|
|
219
223
|
|
|
220
224
|
// A typed usage failure (exit 2) — the codebase's typed-error idiom (no classes).
|
|
@@ -297,7 +301,7 @@ const probeSandboxProvision = ({ root, deps, add, skip }) => {
|
|
|
297
301
|
const variant = plan.tuple ? 'sandbox-provision.installable' : 'sandbox-provision';
|
|
298
302
|
const reason = truncatedTo(oneLineOf(p.reason), templateBudget(WHATS[variant]) - (plan.tuple ? String(plan.tuple).length : 0));
|
|
299
303
|
// The doctor reads process.cwd() (deployment-gated) and takes no --cwd flag — the one-liner
|
|
300
|
-
// pins the target project via a cd prefix (
|
|
304
|
+
// pins the target project via a cd prefix (Segment B).
|
|
301
305
|
add('sandbox-provision', fillTemplate(WHATS[variant], { reason, tuple: plan.tuple }), `cd ${q(root)} && node ${q(toolPath('autonomy-doctor.mjs'))}`);
|
|
302
306
|
} catch (err) {
|
|
303
307
|
skip('sandbox-provision', err);
|
|
@@ -306,7 +310,7 @@ const probeSandboxProvision = ({ root, deps, add, skip }) => {
|
|
|
306
310
|
|
|
307
311
|
const probeReviewRecipe = ({ root, deps, add, skip }) => {
|
|
308
312
|
try {
|
|
309
|
-
// The VALIDATED reader (
|
|
313
|
+
// The VALIDATED reader (Segment B): a schema-invalid config (unknown activity/slot,
|
|
310
314
|
// bad recipe) throws here and becomes a stated skip — raw JSON.parse would silently ignore it.
|
|
311
315
|
const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
|
|
312
316
|
const detection = detectBackends(deps);
|
|
@@ -330,7 +334,7 @@ const probeGates = ({ root, deps, add, skip }) => {
|
|
|
330
334
|
const sg = surveyGateHook(root, deps);
|
|
331
335
|
if (sg.error) throw new Error(sg.error);
|
|
332
336
|
if (sg.declarationPresent && sg.declaredGates === null) throw new Error(sg.declarationError ?? 'gates.json present but unreadable');
|
|
333
|
-
// An ABSENT file and the seeded-EMPTY list are equally undeclared (
|
|
337
|
+
// An ABSENT file and the seeded-EMPTY list are equally undeclared (Segment B); the
|
|
334
338
|
// apply is the consent-gated gates-init PREVIEW (it proposes entries from the project's own
|
|
335
339
|
// scripts and writes only on an explicit yes) — never the runner.
|
|
336
340
|
if (!sg.declarationPresent || sg.declaredGates === 0) {
|
|
@@ -467,7 +471,7 @@ const probeMasksItem = ({ root, deps, add, skip }) => {
|
|
|
467
471
|
if (!needsMasksApply(p)) return;
|
|
468
472
|
const variant = p.staleReal.length > 0 ? 'sandbox-masks.stale-real' : 'sandbox-masks';
|
|
469
473
|
// A stale-real-only fence (EMPTY derivation over a non-empty block) makes the plain --apply
|
|
470
|
-
// REFUSE — the exact one-liner must carry --clear there (
|
|
474
|
+
// REFUSE — the exact one-liner must carry --clear there (Segment B).
|
|
471
475
|
const apply = p.masks.length === 0 && p.staleReal.length > 0 ? `${p.applyCmd} --clear` : p.applyCmd;
|
|
472
476
|
add('sandbox-masks', fillTemplate(WHATS[variant], { n: p.masks.length, m: p.staleReal.length }), apply);
|
|
473
477
|
} catch (err) {
|
|
@@ -480,7 +484,7 @@ const probeAgyAdddir = ({ deps, add, skip }) => {
|
|
|
480
484
|
const probePlaced = deps.findWrapper ?? ((cmd) => findOnPath(cmd, deps).state === 'present');
|
|
481
485
|
if (!probePlaced('agy-review')) return;
|
|
482
486
|
// Configured means a VALID boolean value (the wrapper validates and falls back to the default
|
|
483
|
-
// on garbage — presence alone proves nothing
|
|
487
|
+
// on garbage — presence alone proves nothing). An explicit valid 0 is a user CHOICE
|
|
484
488
|
// (refuse mode) — respected, never nagged. env > file, the wrappers' own precedence.
|
|
485
489
|
const isValidBool = (v) => v === '0' || v === '1';
|
|
486
490
|
const env = deps.getenv ?? process.env;
|
|
@@ -681,7 +685,7 @@ const readReadLaneToggle = (root, deps) => {
|
|
|
681
685
|
// D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
|
|
682
686
|
// at the consent moment; the static contract test asserts EXACT bidirectional coverage
|
|
683
687
|
// (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
|
|
684
|
-
export const RISK_NOTED_KEYS = Object.freeze(['agy-adddir', 'sandbox-lane', 'read-lane']);
|
|
688
|
+
export const RISK_NOTED_KEYS = Object.freeze(['agy-adddir', 'sandbox-lane', 'read-lane', 'worktrees-dir']);
|
|
685
689
|
|
|
686
690
|
const probeSandboxLane = ({ root, deps, add, skip }) => {
|
|
687
691
|
try {
|
|
@@ -738,6 +742,28 @@ const probeSandboxLane = ({ root, deps, add, skip }) => {
|
|
|
738
742
|
}
|
|
739
743
|
};
|
|
740
744
|
|
|
745
|
+
// The worktrees-dir arming item (parallel feature worktrees): fires when the resolved worktrees
|
|
746
|
+
// parent dir (docs/ai/worktrees.json parentDir, else the repo's own parent) is not confirmed
|
|
747
|
+
// writable by a trusted host signal. The provision preflight remains the real create+delete test.
|
|
748
|
+
const probeWorktreesDir = ({ root, deps, add, skip }) => {
|
|
749
|
+
try {
|
|
750
|
+
const config = loadWorktreesConfig(root, deps);
|
|
751
|
+
const parent = config.parentDir == null ? dirname(root) : resolve(root, config.parentDir);
|
|
752
|
+
// the SAME canonical derivation the provision preflight probes (worktrees.mjs) — an absent
|
|
753
|
+
// configured dir resolves to its nearest existing ancestor, never a false denial
|
|
754
|
+
const probeDir = resolveProbeDir(parent, deps);
|
|
755
|
+
if (deps.canWriteDir?.(probeDir) === true) return;
|
|
756
|
+
const dir = truncatedTo(oneLineOf(probeDir), templateBudget(WHATS['worktrees-dir']));
|
|
757
|
+
add(
|
|
758
|
+
'worktrees-dir',
|
|
759
|
+
fillTemplate(WHATS['worktrees-dir'], { dir }),
|
|
760
|
+
`HAND-APPLY: add ${JSON.stringify(probeDir)} to sandbox.filesystem.allowWrite in .claude/settings.json on settings-native hosts; on harness-managed hosts grant this dir for the session or use the provision terminal fallback`,
|
|
761
|
+
);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
skip('worktrees-dir', err);
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
|
|
741
767
|
// ── assembly (frozen presentation order) ─────────────────────────────────────────────────────────
|
|
742
768
|
const PROBES = Object.freeze([
|
|
743
769
|
probeVelocityItems,
|
|
@@ -751,6 +777,7 @@ const PROBES = Object.freeze([
|
|
|
751
777
|
probeMasksItem,
|
|
752
778
|
probeAgyAdddir,
|
|
753
779
|
probeSandboxLane,
|
|
780
|
+
probeWorktreesDir,
|
|
754
781
|
]);
|
|
755
782
|
|
|
756
783
|
export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
|
|
@@ -793,7 +820,7 @@ export const formatRecommendations = ({ items, skips }) => {
|
|
|
793
820
|
const verdict = composeVerdict({ attention, optional: items.length - attention, skipped: skips.length });
|
|
794
821
|
if (verdict == null) {
|
|
795
822
|
// The flow-optimal claim renders ONLY when every probe ran and none fired — an empty item
|
|
796
|
-
// list beside skipped checks would falsely attest optimality (
|
|
823
|
+
// list beside skipped checks would falsely attest optimality (Segment B).
|
|
797
824
|
lines.push(RECOMMENDATIONS_EMPTY_LINE);
|
|
798
825
|
return lines.join('\n');
|
|
799
826
|
}
|
package/tools/release-scan.mjs
CHANGED
|
@@ -35,6 +35,9 @@ const ATTRIBUTION = [
|
|
|
35
35
|
{ re: /reviewed by (claude|codex|chatgpt|gpt|gemini|copilot|cursor|the (ai|model|agent))/i, label: 'AI review attribution' },
|
|
36
36
|
{ re: /authored[- ]by[: ][^\n]{0,40}\b(claude|chatgpt|gemini|copilot)\b/i, label: 'AI authorship attribution' },
|
|
37
37
|
];
|
|
38
|
+
const REVIEWER_IDENTITY = [
|
|
39
|
+
{ re: /\b(?:agy|codex)(?:\s+|-)r\d+(?:(?:\+|\/)r?\d+)*(?:-[a-z0-9]+)*\b/i, label: 'reviewer-round identity' },
|
|
40
|
+
];
|
|
38
41
|
|
|
39
42
|
const allowlistCovers = (matched, allowlist) =>
|
|
40
43
|
allowlist.some((term) => matched.toLowerCase().includes(term.toLowerCase()));
|
|
@@ -49,6 +52,10 @@ export const scanText = (text, { allowlist = ALLOWLIST } = {}) => {
|
|
|
49
52
|
findings.push({ line: idx + 1, kind: 'attribution', detail: `${label}: "${m[0]}"` });
|
|
50
53
|
}
|
|
51
54
|
}
|
|
55
|
+
for (const { re, label } of REVIEWER_IDENTITY) {
|
|
56
|
+
const m = line.match(re);
|
|
57
|
+
if (m) findings.push({ line: idx + 1, kind: 'reviewer-identity', detail: `${label}: "${m[0]}"` });
|
|
58
|
+
}
|
|
52
59
|
});
|
|
53
60
|
return findings;
|
|
54
61
|
};
|
|
@@ -83,7 +90,7 @@ export const scanPaths = (paths, opts = {}) => {
|
|
|
83
90
|
return report;
|
|
84
91
|
};
|
|
85
92
|
|
|
86
|
-
const main = (argv) => {
|
|
93
|
+
export const main = (argv) => {
|
|
87
94
|
const reportOnly = argv.includes('--report');
|
|
88
95
|
const paths = argv.filter((a) => a !== '--report');
|
|
89
96
|
if (paths.length === 0) {
|
|
@@ -93,7 +100,7 @@ const main = (argv) => {
|
|
|
93
100
|
const report = scanPaths(paths);
|
|
94
101
|
for (const r of report) console.log(`[${r.kind}] ${r.file}:${r.line} — ${r.detail}`);
|
|
95
102
|
if (report.length === 0) {
|
|
96
|
-
console.log('[release-scan] clean — no AI attribution found.');
|
|
103
|
+
console.log('[release-scan] clean — no AI attribution or reviewer-round identity found.');
|
|
97
104
|
return;
|
|
98
105
|
}
|
|
99
106
|
console.log(`\n[release-scan] ${report.length} finding(s).`);
|
package/tools/review-state.mjs
CHANGED
|
@@ -162,7 +162,7 @@ export const countNeverCommittableUntracked = (cwd, { lstat = lstatSync } = {})
|
|
|
162
162
|
|
|
163
163
|
// Shell-quote one argument for a COPY-PASTE advisory command: plain safe tokens stay bare; anything
|
|
164
164
|
// else rides single quotes (a space/metacharacter path must never render a dead or unsafe paste —
|
|
165
|
-
//
|
|
165
|
+
// Exported for the sandbox-masks probe, which renders the same apply one-liner.
|
|
166
166
|
export const shellQuoteArg = (s) => (/^[A-Za-z0-9_/.\-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
|
|
167
167
|
|
|
168
168
|
const maskAdvisoryLine = (state) =>
|
|
@@ -282,7 +282,7 @@ export const degradeRecordSet = ({ cwd, env = process.env, fingerprint }) => {
|
|
|
282
282
|
// EVERY project-relative read (orchestration config, docs/plans, receipts) anchors at the git
|
|
283
283
|
// work-tree ROOT when one exists — the fingerprint is root-anchored, so a subdirectory invocation
|
|
284
284
|
// must read the same config/plans or a dirty unreceipted tree could false-PASS as "no plan in
|
|
285
|
-
// flight"
|
|
285
|
+
// flight". Outside a git tree the cwd is the only anchor (and --check exits 0).
|
|
286
286
|
export const buildState = ({ cwd, env = process.env, detect = detectBackends, lstat = lstatSync } = {}) => {
|
|
287
287
|
const root = gitLine(['rev-parse', '--show-toplevel'], cwd) ?? cwd;
|
|
288
288
|
const { config, source: configSource } = loadConfig(root);
|
|
@@ -618,7 +618,7 @@ export const mainAwait = async (argv, ctx = {}) => {
|
|
|
618
618
|
};
|
|
619
619
|
|
|
620
620
|
const emitResult = (r) => {
|
|
621
|
-
// Exact writes + a natural exit: process.exit() can truncate unflushed piped stdio
|
|
621
|
+
// Exact writes + a natural exit: process.exit() can truncate unflushed piped stdio.
|
|
622
622
|
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
623
623
|
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
624
624
|
process.exitCode = r.code;
|
package/tools/sandbox-masks.mjs
CHANGED
|
@@ -69,7 +69,7 @@ const listUntrackedUnfilteredZ = (root) => {
|
|
|
69
69
|
// name can never shadow a deeper real path), glob metacharacters + leading-! / leading-# handled
|
|
70
70
|
// by the anchor, trailing whitespace escaped (gitignore trims it otherwise).
|
|
71
71
|
// Trailing SPACES are escapable in gitignore (`\ `); a trailing TAB is not expressible — such a
|
|
72
|
-
// name is classified unrenderable by the derivation
|
|
72
|
+
// name is classified unrenderable by the derivation, so it never reaches this renderer.
|
|
73
73
|
export const toExcludePattern = (rel) => {
|
|
74
74
|
const escaped = rel.replace(/([\\*?[\]])/g, '\\$1').replace(/ +$/, (m) => '\\ '.repeat(m.length));
|
|
75
75
|
return `/${escaped}`;
|
|
@@ -79,7 +79,7 @@ export const toExcludePattern = (rel) => {
|
|
|
79
79
|
// lstat class is never-committable (char/block/FIFO/socket). Everything else is refused by
|
|
80
80
|
// construction — tracked paths never appear in an --others walk; regular/dir/symlink/missing fail
|
|
81
81
|
// the predicate. A CR/LF-carrying mask NAME cannot be expressed as ONE gitignore rule — rendering
|
|
82
|
-
// it would split into several rules and could hide unrelated committable paths
|
|
82
|
+
// it would split into several rules and could hide unrelated committable paths — so it
|
|
83
83
|
// is a LOUD unrenderable skip, never written (the review domain already ignores the mask itself).
|
|
84
84
|
export const deriveMasks = ({ root, lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ } = {}) => {
|
|
85
85
|
const entries = listUntracked(root);
|
|
@@ -95,7 +95,7 @@ export const deriveMasks = ({ root, lstat = lstatSync, listUntracked = listUntra
|
|
|
95
95
|
}
|
|
96
96
|
if (!isNeverCommittableStat(stat)) continue;
|
|
97
97
|
// CR/LF anywhere, or a trailing TAB (inexpressible in gitignore — only trailing SPACES escape
|
|
98
|
-
// as `\
|
|
98
|
+
// as `\ `), make the name unrenderable as ONE exclude rule.
|
|
99
99
|
if (/[\r\n]/.test(rel) || /\t$/.test(rel)) unrenderable.push(rel);
|
|
100
100
|
else masks.push(rel);
|
|
101
101
|
}
|
|
@@ -115,11 +115,11 @@ export const findMasksFence = (lines) => {
|
|
|
115
115
|
|
|
116
116
|
// A fenced pattern back to its relative path (the derivation writes only `/rel` root-anchored
|
|
117
117
|
// escaped forms; unescape is its exact inverse — the escaped-space form `\ ` included, so a
|
|
118
|
-
// trailing-space mask round-trips
|
|
118
|
+
// trailing-space mask round-trips). Non-pattern lines (blank/comment) are skipped;
|
|
119
119
|
// only UNESCAPED trailing whitespace is insignificant (gitignore semantics).
|
|
120
120
|
const patternToRel = (line) => {
|
|
121
121
|
// A trailing CR (a CRLF-saved exclude file) strips FIRST — it would otherwise ride into the rel
|
|
122
|
-
// name and false-ENOENT the revalidation lstat
|
|
122
|
+
// name and false-ENOENT the revalidation lstat; then only UNESCAPED trailing
|
|
123
123
|
// whitespace is insignificant (gitignore semantics — an escaped `\ ` survives).
|
|
124
124
|
const t = line.replace(/\r$/, '').replace(/^[ \t]+/, '').replace(/(?<!\\)[ \t]+$/, '');
|
|
125
125
|
if (t === '' || t.startsWith('#') || !t.startsWith('/')) return null;
|
|
@@ -156,13 +156,13 @@ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, list
|
|
|
156
156
|
const commonDirRaw = gitLine(['rev-parse', '--git-common-dir'], cwd);
|
|
157
157
|
if (gitPathRaw == null || commonDirRaw == null) return null;
|
|
158
158
|
const excludeFile = resolve(cwd, gitPathRaw);
|
|
159
|
-
// The write-containment root
|
|
159
|
+
// The write-containment root: info/exclude lives in the COMMON git dir (worktree-safe),
|
|
160
160
|
// and the apply must never write through a symlinked component or a non-regular leaf.
|
|
161
161
|
const gitCommonDir = resolve(cwd, commonDirRaw);
|
|
162
162
|
const derived = deriveMasks({ root, lstat, listUntracked });
|
|
163
163
|
if (derived == null) return null;
|
|
164
164
|
const { masks, unrenderable } = derived;
|
|
165
|
-
// The WHOLE chain is guarded BEFORE any read
|
|
165
|
+
// The WHOLE chain is guarded BEFORE any read: a symlinked exclude leaf OR a
|
|
166
166
|
// symlinked parent (.git/info) must never be read through, and a FIFO/socket leaf would HANG
|
|
167
167
|
// the read-only probe — parent-chain containment + leaf lstat first, fail closed on everything
|
|
168
168
|
// but a plain absent file, then read the existing REGULAR content. The apply re-checks
|
|
@@ -190,7 +190,7 @@ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, list
|
|
|
190
190
|
content = readFile(excludeFile, 'utf8');
|
|
191
191
|
} catch (err) {
|
|
192
192
|
// The file existed a moment ago — ANY read failure now fails CLOSED (an apply over a misread
|
|
193
|
-
// "empty" file would overwrite hand content outside the managed block
|
|
193
|
+
// "empty" file would overwrite hand content outside the managed block).
|
|
194
194
|
throw fail(1, `cannot read ${excludeFile} (${err?.code ?? err?.message ?? err}) — refusing to treat an unreadable exclude file as empty`);
|
|
195
195
|
}
|
|
196
196
|
}
|
|
@@ -222,7 +222,7 @@ export const planApply = (probe, { clear = false } = {}) => {
|
|
|
222
222
|
}
|
|
223
223
|
// --clear takes PRECEDENCE over the derivation (codex, Segment-A receipt): it means "remove the
|
|
224
224
|
// managed block", even while masks are visible — re-writing the block instead of the requested
|
|
225
|
-
// clear would be a silent divergence. An EMPTY fence (markers, no entries) clears too
|
|
225
|
+
// clear would be a silent divergence. An EMPTY fence (markers, no entries) clears too;
|
|
226
226
|
// no fence at all is a stated no-op.
|
|
227
227
|
if (clear && probe.fence.state !== 'ok') {
|
|
228
228
|
return { action: 'noop', reason: 'no managed block present — nothing to clear' };
|
|
@@ -236,7 +236,7 @@ export const planApply = (probe, { clear = false } = {}) => {
|
|
|
236
236
|
}
|
|
237
237
|
const masks = clear ? [] : probe.masks;
|
|
238
238
|
const block = masks.length === 0 ? [] : [MASKS_FENCE_START, ...masks.map(toExcludePattern), MASKS_FENCE_END];
|
|
239
|
-
// EVERY hand byte outside the managed fence is preserved EXACTLY
|
|
239
|
+
// EVERY hand byte outside the managed fence is preserved EXACTLY: the first-apply
|
|
240
240
|
// append adds at most ONE separator newline, and the existing-block replace splices via RAW
|
|
241
241
|
// string offsets around the fence lines — a split/join('\n') rebuild would silently normalize
|
|
242
242
|
// CRLF hand content outside the block. Only the fenced block itself is ours (always LF).
|
|
@@ -325,18 +325,18 @@ export const main = (argv, ctx = {}) => {
|
|
|
325
325
|
return { code: 0, stdout: formatProbe(probe), stderr: '' };
|
|
326
326
|
}
|
|
327
327
|
const plan = planApply(probe, { clear: argv.includes('--clear') });
|
|
328
|
-
// The unrenderable skips stay LOUD on EVERY apply path
|
|
328
|
+
// The unrenderable skips stay LOUD on EVERY apply path — the same warning the
|
|
329
329
|
// probe prints; the renderable subset still applies (a refusal would block the useful masks).
|
|
330
330
|
const skipWarnings = probe.unrenderable
|
|
331
331
|
.map((rel) => `sandbox-masks: ⚠ mask name carries a newline or ends with a tab and cannot be expressed as ONE exclude rule — NOT written (the review domain already ignores the mask itself): ${JSON.stringify(rel)}`)
|
|
332
332
|
.join('\n');
|
|
333
|
-
// A refusal carries the unrenderable warnings too
|
|
333
|
+
// A refusal carries the unrenderable warnings too — they are never discarded.
|
|
334
334
|
if (plan.action === 'refuse') throw fail(1, skipWarnings ? `${plan.reason}\n${skipWarnings}` : plan.reason);
|
|
335
335
|
if (plan.action === 'noop') return { code: 0, stdout: `sandbox-masks: ${plan.reason}`, stderr: skipWarnings };
|
|
336
336
|
const writeFile = ctx.deps?.writeFile ?? writeFileSync;
|
|
337
337
|
const mkdir = ctx.deps?.mkdir ?? mkdirSync;
|
|
338
338
|
const lstat = ctx.deps?.lstat ?? lstatSync;
|
|
339
|
-
// The write never follows a symlink or clobbers a non-regular leaf
|
|
339
|
+
// The write never follows a symlink or clobbers a non-regular leaf: every component
|
|
340
340
|
// from the common git dir down is guarded, and an existing exclude leaf must be a regular file.
|
|
341
341
|
try {
|
|
342
342
|
assertContainedRealPath(probe.gitCommonDir, probe.excludeFile, { lstat });
|
package/tools/set-recipe.mjs
CHANGED
|
@@ -210,7 +210,7 @@ export const main = (argv, ctx = {}) => {
|
|
|
210
210
|
const { writtenPath } = writeConfig(cwd, after, ctx);
|
|
211
211
|
const fileBody = serializeConfig(after);
|
|
212
212
|
// The echoed handover line carries the SAME autonomy levels recipes --active-line renders —
|
|
213
|
-
// sync facts (no render-check needed for the cells;
|
|
213
|
+
// sync facts (no render-check needed for the cells; Segment B). A malformed policy
|
|
214
214
|
// surfaces loudly through the line's own MALFORMED segment.
|
|
215
215
|
const autonomyFacts = (() => {
|
|
216
216
|
try {
|
|
@@ -170,7 +170,7 @@ const RUN_GATES_CWD_FLAG = '--cwd';
|
|
|
170
170
|
// final council: a blanket exclusion at opt-in contradicts «never a preemptive blanket»), guided by
|
|
171
171
|
// codex-exec.sh's own nested-sandbox detection hint.
|
|
172
172
|
export const BRIDGE_REVIEW_WRAPPERS = Object.freeze(['codex-review', 'agy-review']);
|
|
173
|
-
// Only the `code` review mode is auto-allowed (
|
|
173
|
+
// Only the `code` review mode is auto-allowed (Segment A): a bare `Bash(<wrapper>:*)`
|
|
174
174
|
// prefix would also cover the plan/diff file-argument modes, whose targets can point OUTSIDE the
|
|
175
175
|
// repo — the tier's stated surface is the unattended council CODE review, so the seeded prefix is
|
|
176
176
|
// `<wrapper> code`. Plan/diff invocations keep their prompt.
|
|
@@ -210,7 +210,7 @@ export const deriveBridgeTierAllowlist = ({ findWrapper, groundingAbsPath } = {}
|
|
|
210
210
|
const allow = placed.map((cmd) => `Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
|
|
211
211
|
const excludedCommands = [...placed];
|
|
212
212
|
// The grounding pre-step exists FOR agy (its grounded --facts reviews; codex grounds natively via
|
|
213
|
-
// the AGENTS.md auto-merge) — a codex-only install must not auto-allow an unused writer
|
|
213
|
+
// the AGENTS.md auto-merge) — a codex-only install must not auto-allow an unused writer.
|
|
214
214
|
if (placed.includes('agy-review')) {
|
|
215
215
|
// groundingAbsPath is a TEST seam only (an unseedable kit path — spaces — is not constructible
|
|
216
216
|
// from a test against the real checkout); production callers never pass it.
|
|
@@ -420,7 +420,7 @@ const validateSettingsShape = (data, relPath) => {
|
|
|
420
420
|
}
|
|
421
421
|
// The bridge tier merges into sandbox.excludedCommands — a malformed (non-array) value is a STOP
|
|
422
422
|
// with ZERO writes (the same fail-closed posture as permissions.allow above), never a silent
|
|
423
|
-
// treat-as-empty overwrite (
|
|
423
|
+
// treat-as-empty overwrite (Segment A).
|
|
424
424
|
if (isJsonObject(data.sandbox) && data.sandbox.excludedCommands !== undefined && !Array.isArray(data.sandbox.excludedCommands)) {
|
|
425
425
|
throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: sandbox.excludedCommands must be an array`);
|
|
426
426
|
}
|
|
@@ -647,8 +647,8 @@ const formatKitTier = (result) =>
|
|
|
647
647
|
]
|
|
648
648
|
: [];
|
|
649
649
|
|
|
650
|
-
// The bridge tier's honest posture, printed on EVERY --bridge-tier run
|
|
651
|
-
//
|
|
650
|
+
// The bridge tier's honest posture, printed on EVERY --bridge-tier run: the informed-consent
|
|
651
|
+
// resolution states the exfiltration surface, never pretends it away.
|
|
652
652
|
export const KIT_BRIDGE_TIER_NOTICE =
|
|
653
653
|
'bridge-wrappers tier: seeds the REVIEW wrappers only, and only their CODE mode (`codex-review code`, `agy-review code` — never codex-exec/agy-run: delegated execution keeps its human prompt; never the plan/diff modes: their file arguments can point outside the repo, so they keep their prompt), each derived ONLY when its bridge is PLACED on PATH, plus the grounding pre-step rule in its rendered quoted byte-form. POSTURE (what this consent covers): an auto-allowed review wrapper runs UNATTENDED — it reads any repo file it is pointed at and sends the assembled payload to its subscription backend, and prefix rules cannot inspect arguments, so a code-mode argument that names a readable file (agy\'s --facts/--decided) rides the same consent — the same documented residual class as the autonomy red-line rules; that is the tier\'s PURPOSE (unattended council review runs) and its residual — tier entries get NO PreToolUse-hook coverage. The grounding entry\'s writer surface is bounded by grounding.mjs\'s OWN scratch-destination guard (a tracked or in-repo-not-ignored --out is refused by the tool). The wrapper names are ALSO seeded into sandbox.excludedCommands IN THE PROJECT settings.json (an exclusion only in settings.local.json was live-observed NOT to route — the wrapper then runs sandboxed and dies on a read-only HOME): the harness runs an excluded command OUTSIDE the sandbox (the wrappers need network), so a plain allowlisted invocation triggers no sandbox-bypass approval. INVOCATION SHAPE: a prefix rule matches only a PLAIN invocation starting with the wrapper name — an env-var prefix or a compound chain never matches (redirects are fine).';
|
|
654
654
|
|
|
@@ -722,7 +722,7 @@ export const screenAllowlistEntry = (pattern) => {
|
|
|
722
722
|
}
|
|
723
723
|
// Bridge-review-wrapper class (AD-044 Plan 4): EXACTLY `<frozen-tier wrapper> code` + the args
|
|
724
724
|
// wildcard — never codex-exec/agy-run, never the bare or plan/diff spellings (those file-argument
|
|
725
|
-
// modes can read outside the repo
|
|
725
|
+
// modes can read outside the repo).
|
|
726
726
|
if (BRIDGE_REVIEW_WRAPPERS.includes(tokens[0])) return tokens.length === 2 && tokens[1] === BRIDGE_REVIEW_MODE;
|
|
727
727
|
return isSingleShellToken(wildcardCmd, tokens) && SHELL_READONLY.includes(wildcardCmd);
|
|
728
728
|
}
|
|
@@ -1196,7 +1196,7 @@ const collectRedlineBypass = (sources) =>
|
|
|
1196
1196
|
// sub-keys (merge-don't-clobber, never a silent clobber of the user's sandbox tuning), so a pre-existing
|
|
1197
1197
|
// weakening sub-key is REPORTED loudly (remove it by hand) — never silently carried as security.
|
|
1198
1198
|
const collectSandboxWeakenings = (sources) => {
|
|
1199
|
-
// Tier-known PROOF
|
|
1199
|
+
// Tier-known PROOF: an excludedCommands entry is downgraded to a note ONLY when it is
|
|
1200
1200
|
// demonstrably the consented tier's own output — it lives in the PROJECT settings.json (the file
|
|
1201
1201
|
// the tier writes; a local-file exclusion is never tier output) AND the matching derived
|
|
1202
1202
|
// code-mode allow rule is present there. A bare name match alone proves nothing.
|