@sabaiway/agent-workflow-kit 5.2.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +11 -3
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +154 -35
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +257 -4
- package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/references/driving-codex.md +5 -3
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +33 -15
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/core-evidence.md +1 -1
- package/references/modes/coverage-check.md +1 -1
- package/references/modes/gates.md +7 -2
- package/references/modes/recommendations.md +3 -1
- package/references/modes/upgrade.md +1 -1
- package/references/modes/velocity.md +5 -1
- package/references/scripts/archive-decisions.mjs +340 -15
- package/references/scripts/archive-decisions.test.mjs +522 -2
- package/references/scripts/migrate-gates.mjs +102 -10
- package/references/scripts/migrate-gates.test.mjs +37 -0
- package/tools/core-evidence.mjs +42 -2
- package/tools/coverage-check.mjs +23 -7
- package/tools/coverage-producer.mjs +68 -0
- package/tools/coverage-state.mjs +24 -0
- package/tools/declared-paths.mjs +32 -0
- package/tools/detect-backends.mjs +1 -0
- package/tools/dispatch-record.mjs +926 -0
- package/tools/doc-parity.mjs +19 -4
- package/tools/flow-check.mjs +48 -12
- package/tools/gates-declaration.mjs +49 -0
- package/tools/gates-init.mjs +83 -6
- package/tools/recommendations.mjs +63 -19
- package/tools/run-gates.mjs +111 -32
- package/tools/velocity-profile.mjs +102 -23
package/tools/run-gates.mjs
CHANGED
|
@@ -68,7 +68,13 @@ import {
|
|
|
68
68
|
isReviewDependentGate, unknownPregateExcludeIds, derivePregateSubsetGates,
|
|
69
69
|
} from './gates-declaration.mjs';
|
|
70
70
|
|
|
71
|
+
// The coverage vocabulary lives in a LEAF below this runner AND core-evidence (which validates the
|
|
72
|
+
// token this runner records) — run-gates already imports core-evidence, so a shared home is the
|
|
73
|
+
// only direction that has no cycle. Re-exported here: this module is the vocabulary's public face.
|
|
74
|
+
import { COVERAGE } from './coverage-state.mjs';
|
|
75
|
+
|
|
71
76
|
export { GATES_REL, loadDeclaration, validateDeclaration, canonicalCheckerGates, isFinalCapableDeclaration, isReviewDependentGate };
|
|
77
|
+
export { COVERAGE };
|
|
72
78
|
|
|
73
79
|
// The full exit-code table — one distinct code per honest outcome (never a silent green).
|
|
74
80
|
// 7 is RETIRED (the deleted --record arm's outcome) — never reused for a new meaning.
|
|
@@ -117,12 +123,18 @@ const USAGE = [
|
|
|
117
123
|
'deletes the stale git-dir lcov first, exports AW_GIT_DIR to every gate cmd, records EVERY',
|
|
118
124
|
'attempt (start + completed green/red) in the core-evidence store, and binds the receipt to',
|
|
119
125
|
'{ fingerprint before/after, the full declaration, per-gate results, the canonical red-proof +',
|
|
120
|
-
'degrade evidence hashes, the lcov sha, and — when a flow store
|
|
121
|
-
'the owner-scoped flow projection hash (D10; projection movement
|
|
122
|
-
'integrityFailure) }. --final refuses --only (a subset never attests).',
|
|
126
|
+
'degrade evidence hashes, the lcov sha, the run\'s own coverage token, and — when a flow store',
|
|
127
|
+
'exists — evidenceHashes.flow, the owner-scoped flow projection hash (D10; projection movement',
|
|
128
|
+
'under the run is a red integrityFailure) }. --final refuses --only (a subset never attests).',
|
|
123
129
|
'',
|
|
124
130
|
`Runs the gates declared in <cwd>/${GATES_REL} (one bash command line each, project root as cwd).`,
|
|
125
131
|
'Prints a per-gate PASS/FAIL table + one machine-readable summary line; exit 0 iff all green.',
|
|
132
|
+
`The summary line carries coverage=<${COVERAGE.certified}|${COVERAGE.notRun}|${COVERAGE.none}|${COVERAGE.unknown}> — whether a coverage`,
|
|
133
|
+
'VERDICT rode this run: certified (one was issued, pass OR fail), not-run (the checker issued',
|
|
134
|
+
'none — no lcov, or this run does not own it), none (no canonical checker ran here), unknown (the',
|
|
135
|
+
'run ended before the gates produced a signal, or the signal is unreadable). The checker\'s own',
|
|
136
|
+
'table row names a withheld verdict in the same words. DETAIL only: the exit code and the status=',
|
|
137
|
+
'token are untouched by it.',
|
|
126
138
|
'',
|
|
127
139
|
'Producer env: AW_GIT_DIR (inside a git tree) and AW_LCOV_FILE (--final only) are computed and',
|
|
128
140
|
'exported to every gate child, and STRIPPED from the inherited environment first — a host-set',
|
|
@@ -238,23 +250,82 @@ export const runGates = (gates, { cwd, spawn = spawnGateViaBash, now = Date.now,
|
|
|
238
250
|
};
|
|
239
251
|
|
|
240
252
|
// The per-gate PASS/FAIL table (printed after every gate ran — failures never stop the matrix).
|
|
241
|
-
|
|
253
|
+
// `notes` annotates rows by gate id: the coverage signal names a WITHHELD verdict on the checker's
|
|
254
|
+
// own row, so a PASS there can never read as a claim the checker did not make. A Map, not an
|
|
255
|
+
// object — a gate id is any kebab word, `constructor` included, and a plain object would answer
|
|
256
|
+
// that lookup from its prototype.
|
|
257
|
+
export const formatTable = (results, notes = new Map()) => {
|
|
242
258
|
const idWidth = Math.max(...results.map((result) => result.id.length), 'gate'.length);
|
|
243
259
|
const pad = (text) => text + ' '.repeat(idWidth - text.length);
|
|
244
260
|
const lines = ['', `${pad('gate')} result`];
|
|
245
261
|
for (const result of results) {
|
|
246
|
-
|
|
262
|
+
const note = notes.get(result.id);
|
|
263
|
+
lines.push(`${pad(result.id)} ${result.ok ? 'PASS' : `FAIL (exit ${result.code})`}${note ? ` ${note}` : ''}`);
|
|
247
264
|
}
|
|
248
265
|
return lines;
|
|
249
266
|
};
|
|
250
267
|
|
|
268
|
+
// ── the coverage signal this run carries (Decision 8) ─────────────────────────────────
|
|
269
|
+
// The checker's two fully anchored machine lines. Exactly ONE of each rides a run: the --final
|
|
270
|
+
// receipt binds them (an injected or duplicated line must never shadow the real one), and the
|
|
271
|
+
// summary field below is derived from the SAME bytes, so the two can never disagree.
|
|
272
|
+
const LCOV_SHA_LINE_RE = /^coverage-check: lcov-sha256=([0-9a-f]{64}|none)$/;
|
|
273
|
+
const ATTESTED_LINE_RE = /^coverage-check: attested=(yes|no)$/;
|
|
274
|
+
const anchoredMachineLines = (stdout, re) => String(stdout ?? '').split(/\r?\n/).filter((line) => re.test(line));
|
|
275
|
+
const exactlyOneMachineValue = (stdout, re) => {
|
|
276
|
+
const lines = anchoredMachineLines(stdout, re);
|
|
277
|
+
return lines.length === 1 ? re.exec(lines[0])[1] : null;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Which SELECTED gate is the canonical checker — resolved BEFORE anything spawns, because the
|
|
281
|
+
// answer is a property of the DECLARATION, not of the post-run filesystem. The predicate reads the
|
|
282
|
+
// tool path through realpath, so a gate that deletes or redirects it mid-run would otherwise turn
|
|
283
|
+
// the checker this run selected into "no checker at all" (`none`) — and a --final receipt refuses
|
|
284
|
+
// that state, which would lose the whole attempt's evidence rather than record it honestly.
|
|
285
|
+
export const resolveCheckerIndex = (selected, projectDir) =>
|
|
286
|
+
selected.findIndex((gate) => matchesCanonicalCheck(FINAL_CORE_CHECKS[1], gate.cmd, projectDir));
|
|
287
|
+
|
|
288
|
+
// coverageSignal(checkerAt, results) → { state, note, checkerRow } — what THIS run can honestly say
|
|
289
|
+
// about coverage, decided by the checker's own OUTPUT rather than by its exit status (it exits 0
|
|
290
|
+
// both when it certifies and when it withholds). BOTH anchored lines are read and CROSS-CHECKED:
|
|
291
|
+
// neither alone is trustworthy — a missing or duplicated line is unreadable, and an attestation
|
|
292
|
+
// over bytes that were never consumed is a contradiction. `note` is the checker row's table
|
|
293
|
+
// annotation, null when there is nothing to name.
|
|
294
|
+
export const coverageSignal = (checkerAt, results) => {
|
|
295
|
+
if (checkerAt === -1) return { state: COVERAGE.none, note: null, checkerRow: null };
|
|
296
|
+
const checkerRow = results[checkerAt] ?? null;
|
|
297
|
+
if (checkerRow === null || checkerRow.code === SPAWN_FAILED_CODE) {
|
|
298
|
+
return { state: COVERAGE.unknown, note: null, checkerRow };
|
|
299
|
+
}
|
|
300
|
+
const unreadable = (why) => ({ state: COVERAGE.unknown, note: `coverage=${COVERAGE.unknown} (${why})`, checkerRow });
|
|
301
|
+
const attested = exactlyOneMachineValue(checkerRow.stdout, ATTESTED_LINE_RE);
|
|
302
|
+
const consumed = exactlyOneMachineValue(checkerRow.stdout, LCOV_SHA_LINE_RE);
|
|
303
|
+
if (attested === null || consumed === null) {
|
|
304
|
+
return unreadable('the checker printed no single anchored attested= / lcov-sha256 pair — whether coverage was certified is unknowable');
|
|
305
|
+
}
|
|
306
|
+
if (attested === 'yes') {
|
|
307
|
+
return consumed === 'none'
|
|
308
|
+
? unreadable('the checker attested over an lcov it never read — a contradictory pair, fail closed')
|
|
309
|
+
: { state: COVERAGE.certified, note: null, checkerRow };
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
state: COVERAGE.notRun,
|
|
313
|
+
note: consumed === 'none'
|
|
314
|
+
? `coverage=${COVERAGE.notRun} (no lcov bytes were read; no coverage verdict was issued)`
|
|
315
|
+
: `coverage=${COVERAGE.notRun} (an lcov was read but no verdict was issued)`,
|
|
316
|
+
checkerRow,
|
|
317
|
+
};
|
|
318
|
+
};
|
|
319
|
+
|
|
251
320
|
// The ONE machine-readable summary line — always the LAST line printed for every non-usage
|
|
252
|
-
// outcome. Schema (pinned by tests): status ∈ ok|fail|missing|empty|malformed|no-bash
|
|
253
|
-
|
|
321
|
+
// outcome. Schema (pinned by tests): status ∈ ok|fail|missing|empty|malformed|no-bash, plus
|
|
322
|
+
// coverage ∈ the COVERAGE vocabulary. The coverage DEFAULT is `unknown`: a lane that ended before
|
|
323
|
+
// the gates could produce a signal says exactly that, never a claim it cannot support.
|
|
324
|
+
export const composeSummaryLine = ({ status, results = [], coverage = COVERAGE.unknown }) => {
|
|
254
325
|
const passed = results.filter((result) => result.ok).length;
|
|
255
326
|
const failed = results.filter((result) => !result.ok);
|
|
256
327
|
const failedIds = failed.length > 0 ? failed.map((result) => result.id).join(',') : NO_FAILED_IDS;
|
|
257
|
-
return `[run-gates] status=${status} gates=${results.length} passed=${passed} failed=${failed.length} failed_ids=${failedIds}`;
|
|
328
|
+
return `[run-gates] status=${status} gates=${results.length} passed=${passed} failed=${failed.length} failed_ids=${failedIds} coverage=${coverage}`;
|
|
258
329
|
};
|
|
259
330
|
|
|
260
331
|
// ── CLI ───────────────────────────────────────────────────────────────────────────────
|
|
@@ -655,8 +726,15 @@ export const runCli = (argv, deps = {}) => {
|
|
|
655
726
|
return EXIT.finalFailed;
|
|
656
727
|
}
|
|
657
728
|
}
|
|
729
|
+
// The checker's position is pinned BEFORE the matrix spawns; the tree it leaves behind never
|
|
730
|
+
// gets to re-decide which gate this run selected.
|
|
731
|
+
const checkerAt = resolveCheckerIndex(selected, projectDir);
|
|
658
732
|
const results = runGates(selected, { cwd: projectDir, spawn: gateSpawn, log, now });
|
|
659
|
-
|
|
733
|
+
// Decided once, from the checker's own machine lines, and carried to BOTH surfaces of this run:
|
|
734
|
+
// the checker's table row and the machine summary field (Decision 7/8).
|
|
735
|
+
const coverage = coverageSignal(checkerAt, results);
|
|
736
|
+
const coverageNotes = coverage.note && coverage.checkerRow ? new Map([[coverage.checkerRow.id, coverage.note]]) : new Map();
|
|
737
|
+
for (const line of formatTable(results, coverageNotes)) log(line);
|
|
660
738
|
const allGreen = results.every((result) => result.ok);
|
|
661
739
|
if (opts.preReview) {
|
|
662
740
|
// The named diagnosis (#66): review-dependence is derived, so an abstracted checker can only
|
|
@@ -675,7 +753,7 @@ export const runCli = (argv, deps = {}) => {
|
|
|
675
753
|
&& endRead.records.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption');
|
|
676
754
|
if (armedNow) {
|
|
677
755
|
logError('[run-gates] --pre-review: the flow was ARMED while this run executed — the run started unarmed, so its result is NOT recorded (round-11 fold); re-run under the armed flow');
|
|
678
|
-
log(composeSummaryLine({ status: 'fail', results }));
|
|
756
|
+
log(composeSummaryLine({ status: 'fail', results, coverage: coverage.state }));
|
|
679
757
|
return EXIT.fail;
|
|
680
758
|
}
|
|
681
759
|
}
|
|
@@ -686,7 +764,7 @@ export const runCli = (argv, deps = {}) => {
|
|
|
686
764
|
if (results.some((result) => result.code === SPAWN_FAILED_CODE)) {
|
|
687
765
|
logError('[run-gates] --pre-review: a gate could not SPAWN — an infrastructure failure is not a gate red, so NO subset-attempt was recorded; fix the spawn failure and re-run');
|
|
688
766
|
releaseSubsetRunLock();
|
|
689
|
-
log(composeSummaryLine({ status: 'fail', results }));
|
|
767
|
+
log(composeSummaryLine({ status: 'fail', results, coverage: coverage.state }));
|
|
690
768
|
return EXIT.fail;
|
|
691
769
|
}
|
|
692
770
|
const attemptStatus = allGreen ? 'green' : 'red';
|
|
@@ -711,23 +789,18 @@ export const runCli = (argv, deps = {}) => {
|
|
|
711
789
|
logError(`[run-gates] --pre-review: the subset attempt could not be recorded — ${err.message}`);
|
|
712
790
|
logError("[run-gates] an armed flow's subset run IS a recorded attempt; an unrecordable run refuses (fail closed)");
|
|
713
791
|
releaseSubsetRunLock();
|
|
714
|
-
log(composeSummaryLine({ status: 'fail', results }));
|
|
792
|
+
log(composeSummaryLine({ status: 'fail', results, coverage: coverage.state }));
|
|
715
793
|
return EXIT.fail;
|
|
716
794
|
}
|
|
717
795
|
}
|
|
718
796
|
}
|
|
719
|
-
// A green gate's stdout is deliberately not echoed — the table IS the report.
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
if (checkerRow?.ok && /^coverage-check: attested=no$/m.test(String(checkerRow.stdout ?? ''))) {
|
|
727
|
-
log(`── ${checkerRow.id} — NO COVERAGE VERDICT (the gate passed; it did not certify)`);
|
|
728
|
-
for (const line of String(checkerRow.stdout).split(/\r?\n/).filter((l) => /^coverage-check: (NO VERDICT|skipped-no-lcov)/.test(l))) log(line);
|
|
729
|
-
log(' A coverage verdict is issued only by run-gates.mjs --final, which owns the lcov for the whole run.');
|
|
730
|
-
}
|
|
797
|
+
// A green gate's stdout is deliberately not echoed — the table IS the report. The row above
|
|
798
|
+
// now names the withheld verdict; this block adds the checker's OWN words and the remedy, so a
|
|
799
|
+
// plain run never leaves a PASS standing for a coverage claim that was never made. Only that.
|
|
800
|
+
if (!opts.final && coverage.state === COVERAGE.notRun && coverage.checkerRow?.ok) {
|
|
801
|
+
log(`── ${coverage.checkerRow.id} — NO COVERAGE VERDICT (the gate passed; it did not certify)`);
|
|
802
|
+
for (const line of String(coverage.checkerRow.stdout).split(/\r?\n/).filter((l) => /^coverage-check: (NO VERDICT|skipped-no-lcov)/.test(l))) log(line);
|
|
803
|
+
log(' A coverage verdict is issued only by run-gates.mjs --final, which owns the lcov for the whole run.');
|
|
731
804
|
}
|
|
732
805
|
if (opts.final) {
|
|
733
806
|
// The checker's verbatim diagnostics surface even on green — skipped-no-lcov and the
|
|
@@ -770,17 +843,15 @@ export const runCli = (argv, deps = {}) => {
|
|
|
770
843
|
}
|
|
771
844
|
// Exactly ONE full machine line binds the receipt — an unanchored first-match would let
|
|
772
845
|
// an injected/duplicated line shadow the real one and skip the end re-hash.
|
|
773
|
-
const
|
|
774
|
-
const
|
|
775
|
-
const shaValue = shaLines.length === 1 ? shaLineRe.exec(shaLines[0])[1] : null;
|
|
846
|
+
const shaLines = anchoredMachineLines(checkerRow?.stdout, LCOV_SHA_LINE_RE);
|
|
847
|
+
const shaValue = shaLines.length === 1 ? LCOV_SHA_LINE_RE.exec(shaLines[0])[1] : null;
|
|
776
848
|
const lcovSha256 = shaValue !== null && shaValue !== 'none' ? shaValue : null;
|
|
777
849
|
// The attestation line, on the SAME exactly-one-anchored-line contract as the sha: a green
|
|
778
850
|
// exit status alone never proves the checker certified anything — it exits 0 both when it
|
|
779
851
|
// attests and when it withholds a verdict. Without this arm a gate that removed the start
|
|
780
852
|
// record mid-run would yield a green receipt carrying no coverage claim at all.
|
|
781
|
-
const
|
|
782
|
-
const
|
|
783
|
-
const attested = attestLines.length === 1 ? attestLineRe.exec(attestLines[0])[1] : null;
|
|
853
|
+
const attestLines = anchoredMachineLines(checkerRow?.stdout, ATTESTED_LINE_RE);
|
|
854
|
+
const attested = attestLines.length === 1 ? ATTESTED_LINE_RE.exec(attestLines[0])[1] : null;
|
|
784
855
|
if (allGreen && integrityFailure === null && lcovSha256 !== null) {
|
|
785
856
|
if (attestLines.length !== 1) {
|
|
786
857
|
integrityFailure = attestLines.length === 0
|
|
@@ -826,6 +897,11 @@ export const runCli = (argv, deps = {}) => {
|
|
|
826
897
|
...(finalFlow?.present ? { flow: finalFlow.hash } : {}),
|
|
827
898
|
},
|
|
828
899
|
lcovSha256,
|
|
900
|
+
// The run's OWN coverage token, recorded rather than re-derived: `lcovSha256` says what
|
|
901
|
+
// the receipt binds, never whether a verdict was issued (a red run can bind a digest
|
|
902
|
+
// over an uncertified read, and a null digest is also what an unreadable sha line
|
|
903
|
+
// leaves). The stateless render reads this field instead of guessing from the digest.
|
|
904
|
+
coverage: coverage.state,
|
|
829
905
|
integrityFailure,
|
|
830
906
|
timestamp: new Date().toISOString(),
|
|
831
907
|
},
|
|
@@ -848,14 +924,17 @@ export const runCli = (argv, deps = {}) => {
|
|
|
848
924
|
// would be a silent green in the one place a reader parses instead of reads. The run lock
|
|
849
925
|
// releases BEFORE the line composes so a custody violation can never hide behind status=ok.
|
|
850
926
|
const runLockIssue = releaseSubsetRunLock();
|
|
851
|
-
log(composeSummaryLine({ status: allGreen && finalError === null && runLockIssue == null ? 'ok' : 'fail', results }));
|
|
927
|
+
log(composeSummaryLine({ status: allGreen && finalError === null && runLockIssue == null ? 'ok' : 'fail', results, coverage: coverage.state }));
|
|
852
928
|
if (finalError) return EXIT.finalFailed;
|
|
853
929
|
if (runLockIssue != null) return EXIT.fail;
|
|
854
930
|
return allGreen ? EXIT.ok : EXIT.fail;
|
|
855
931
|
} catch (err) {
|
|
856
932
|
releaseSubsetRunLock();
|
|
857
933
|
logError(`[run-gates] ${err.message}`);
|
|
858
|
-
|
|
934
|
+
// The machine line is the LAST line for every NON-USAGE outcome — a thrown refusal is one, and
|
|
935
|
+
// the gates never produced a signal there, so it carries coverage=unknown. Usage is the single
|
|
936
|
+
// documented exception: it prints the usage text and no summary at all.
|
|
937
|
+
if (err.exitCode !== EXIT.usage) log(composeSummaryLine({ status: err.exitCode === EXIT.malformed ? 'malformed' : 'fail' }));
|
|
859
938
|
return err.exitCode ?? EXIT.fail;
|
|
860
939
|
}
|
|
861
940
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
3
4
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
5
|
// The --autonomy render reads the per-project autonomy policy through the read-only autonomy core
|
|
5
6
|
// (AD-044). This file is the family's one .claude/settings.json writer, so the policy render lives here;
|
|
@@ -10,6 +11,9 @@ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from '.
|
|
|
10
11
|
// for a PLACED bridge wrapper — findOnPath is the same read-only PATH scan the backend detector uses.
|
|
11
12
|
import { findOnPath } from './detect-backends.mjs';
|
|
12
13
|
import { compareSemver } from './semver-lite.mjs';
|
|
14
|
+
// The declared-path resolution + segment containment the allowWrite degrade shares with the
|
|
15
|
+
// advisor's worktrees-dir convergence lane — ONE leaf, so the two readings cannot drift.
|
|
16
|
+
import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
|
|
13
17
|
|
|
14
18
|
// Velocity-profile core + writer: a fixed, audited read-only allowlist that an onboarding step seeds
|
|
15
19
|
// into `.claude/settings.json` so routine read-only commands stop idling on approval prompts.
|
|
@@ -337,6 +341,17 @@ const MUTATING_SCRIPT_HOOK_PATTERN = /^(pre|post)/iu;
|
|
|
337
341
|
// invariant (kept deliberately even though the read-only screen already rejects these, so the
|
|
338
342
|
// refusal is named, tested, and produces a clear message).
|
|
339
343
|
const MUTATING_ALLOW_COMMAND_PATTERN = /^(?:git\s+(?:commit|push)|npm\s+publish)(?:\s|$)/iu;
|
|
344
|
+
// Whether a host APPLIES a `sandbox.*` settings key is not knowable from here: a settings-native
|
|
345
|
+
// host honors it, while an IDE/session-imposed sandbox was observed ignoring the hand-applied
|
|
346
|
+
// security keys in BOTH scopes (the advisor records the same limit in
|
|
347
|
+
// references/modes/recommendations.md). So every claim about the RUNTIME EFFECT of a settings key —
|
|
348
|
+
// the tier's exclusion routing and every weakening detail alike — carries this qualifier. The
|
|
349
|
+
// CLASSIFICATION is untouched: which red-line a key would weaken, and that a proven tier exclusion
|
|
350
|
+
// is a note rather than a weakening, are properties of the DECLARATION, not of a host.
|
|
351
|
+
export const HOST_HONORS_QUALIFIER = 'where the host honors the settings sandbox keys';
|
|
352
|
+
export const HOST_HONORS_NOTICE =
|
|
353
|
+
`host-conditional: whether a host applies the sandbox.* settings keys is not knowable from here — a settings-native host honors them, a harness-managed session sandbox may ignore them in BOTH scopes, so each runtime effect above is stated "${HOST_HONORS_QUALIFIER}" rather than promised; what a key would weaken is a property of the declaration and is stated flat.`;
|
|
354
|
+
|
|
340
355
|
const RESIDUAL_NOTICE =
|
|
341
356
|
'residual: seeded read-only allow entries are a trust-posture convenience, NOT a sandbox; settings-level rules cannot inspect runtime redirection/command-substitution/--output writes; commit/push/publish are never allowlisted (a DIRECT invocation still ASKs, but the runtime residual is not closed here); the residual guard ships as the opt-in PreToolUse hook — Mode: hook (/agent-workflow-kit hook). floor (never auto-approved, with or without the tier): every writer --apply/--write/--yes still prompts; clobber-protection STOPs still stop; the three release asks (commit/push/publish) stay maintainer-owned.';
|
|
342
357
|
|
|
@@ -353,9 +368,9 @@ preview byte-strings. Never touches settings.local.json.
|
|
|
353
368
|
--bridge-tier (own consent) seeds the bridge REVIEW wrappers' CODE mode for PLACED bridges
|
|
354
369
|
(codex-review code, agy-review code - never the execution/probe wrappers, never plan/diff modes)
|
|
355
370
|
+ the quoted grounding pre-step rule, and the wrapper names into sandbox.excludedCommands (they
|
|
356
|
-
need network - the harness runs them outside the sandbox). Consented
|
|
357
|
-
review wrapper runs UNATTENDED and sends the assembled repo payload to its
|
|
358
|
-
(see the printed tier notice).
|
|
371
|
+
need network - ${HOST_HONORS_QUALIFIER} the harness runs them outside the sandbox). Consented
|
|
372
|
+
posture: an auto-allowed review wrapper runs UNATTENDED and sends the assembled repo payload to its
|
|
373
|
+
subscription backend (see the printed tier notice).
|
|
359
374
|
|
|
360
375
|
--autonomy renders docs/ai/autonomy.json into the settings blocks it OWNS — the sandbox block +
|
|
361
376
|
permissions.ask/deny red-lines + permissions.defaultMode. POLICY-ONLY: never seeds the allowlist and
|
|
@@ -663,7 +678,10 @@ const formatKitTier = (result) =>
|
|
|
663
678
|
// The bridge tier's honest posture, printed on EVERY --bridge-tier run: the informed-consent
|
|
664
679
|
// resolution states the exfiltration surface, never pretends it away.
|
|
665
680
|
export const KIT_BRIDGE_TIER_NOTICE =
|
|
666
|
-
'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):
|
|
681
|
+
'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): ' +
|
|
682
|
+
// The one interpolated seam in this notice: the shared host-conditional qualifier, so the tier's
|
|
683
|
+
// routing promise and the render's degrade lines can never drift apart.
|
|
684
|
+
`${HOST_HONORS_QUALIFIER} the harness runs an excluded command OUTSIDE the sandbox (the wrappers need network), so a plain allowlisted invocation triggers no sandbox-bypass approval — whether a host honors them is not knowable from here, and where it does not the wrapper simply starts sandboxed (fail-safe, never a silent widening). 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).`;
|
|
667
685
|
|
|
668
686
|
const formatBridgeTier = (result) =>
|
|
669
687
|
result.bridgeTier
|
|
@@ -1280,9 +1298,9 @@ export const renderAutonomySettings = (resolved, probe, harness = HARNESS_UNPROB
|
|
|
1280
1298
|
// degrade states what THIS RENDER does not express; it never claims a platform limit it did not
|
|
1281
1299
|
// observe, and it names the version it did observe (or states the unknown).
|
|
1282
1300
|
if (resolved.redlines.network === 'deny') {
|
|
1283
|
-
degrades.push(`network=deny requested, but this render expresses no HARD egress block (${observedPhrase(harness)}) — rendered as prompt-on-egress (the sandbox default: no domains pre-allowed, a new domain still prompts). A silent hard block needs managed settings (allowManagedDomainsOnly).`);
|
|
1301
|
+
degrades.push(`network=deny requested, but this render expresses no HARD egress block (${observedPhrase(harness)}) — rendered as prompt-on-egress (the sandbox default: no domains pre-allowed, a new domain still prompts ${HOST_HONORS_QUALIFIER}). A silent hard block needs managed settings (allowManagedDomainsOnly).`);
|
|
1284
1302
|
} else {
|
|
1285
|
-
notes.push(
|
|
1303
|
+
notes.push(`network=ask → prompt on each new domain ${HOST_HONORS_QUALIFIER} (the sandbox default; no domains pre-allowed).`);
|
|
1286
1304
|
}
|
|
1287
1305
|
// credentials — rendered when the OBSERVED version reaches the threshold, degraded loudly when it
|
|
1288
1306
|
// does not or when the version is unknown. Reporting a platform limit that does not exist is the
|
|
@@ -1294,23 +1312,23 @@ export const renderAutonomySettings = (resolved, probe, harness = HARNESS_UNPROB
|
|
|
1294
1312
|
// installed build. Partial protection reported as success is the same defect as the false claim
|
|
1295
1313
|
// this render replaced — so it degrades too, never a note.
|
|
1296
1314
|
if (resolved.redlines.credentials === 'ask') {
|
|
1297
|
-
degrades.push(`credentials=ask requested, but sandbox.${CREDENTIALS_KEY} offers no ask mode (${observedPhrase(harness)}) — rendered as the deny form: ${PROTECTED_ENV_VARS.join('/')} are unset for sandboxed commands with no prompt.`);
|
|
1315
|
+
degrades.push(`credentials=ask requested, but sandbox.${CREDENTIALS_KEY} offers no ask mode (${observedPhrase(harness)}) — rendered as the deny form: ${PROTECTED_ENV_VARS.join('/')} are unset for sandboxed commands with no prompt, ${HOST_HONORS_QUALIFIER}.`);
|
|
1298
1316
|
}
|
|
1299
|
-
degrades.push(`credentials=${resolved.redlines.credentials} coverage is PARTIAL (${observedPhrase(harness)}) — sandbox.${CREDENTIALS_KEY} denies ${PROTECTED_ENV_VARS.join('/')} only. File-based credentials (~/.ssh and any other secret FILE) stay readable by sandboxed commands: this profile does not render ${CREDENTIALS_KEY}.files, whose entry shape it has not verified against an installed build. Declare them yourself if you need them.`);
|
|
1317
|
+
degrades.push(`credentials=${resolved.redlines.credentials} coverage is PARTIAL (${observedPhrase(harness)}) — ${HOST_HONORS_QUALIFIER}, sandbox.${CREDENTIALS_KEY} denies ${PROTECTED_ENV_VARS.join('/')} only. File-based credentials (~/.ssh and any other secret FILE) stay readable by sandboxed commands: this profile does not render ${CREDENTIALS_KEY}.files, whose entry shape it has not verified against an installed build. Declare them yourself if you need them.`);
|
|
1300
1318
|
} else {
|
|
1301
|
-
degrades.push(`credentials=${resolved.redlines.credentials} requested, but ${observedPhrase(harness)}; sandbox credential denial arrived in ${CREDENTIALS_DENY_SINCE}, so ${PROTECTED_ENV_VARS.join('/')} and ~/.ssh are
|
|
1319
|
+
degrades.push(`credentials=${resolved.redlines.credentials} requested, but ${observedPhrase(harness)}; sandbox credential denial arrived in ${CREDENTIALS_DENY_SINCE}, so THIS RENDER hides nothing: ${PROTECTED_ENV_VARS.join('/')} and ~/.ssh are not hidden by any key it writes (a host sandbox of its own may still hide them — that is not knowable from here, the mirror image of ${HOST_HONORS_QUALIFIER}). Upgrade to ${CREDENTIALS_DENY_SINCE}+ (or fix the install so the version can be read) for sandbox.${CREDENTIALS_KEY}.`);
|
|
1302
1320
|
}
|
|
1303
1321
|
// fs_outside_repo — the sandbox default is a HARD confine to cwd+$TMPDIR; this render expresses no
|
|
1304
1322
|
// prompt-on-outside-write mode, so `ask` DEGRADES LOUDLY to the deny form (hard confine).
|
|
1305
1323
|
if (resolved.redlines.fs_outside_repo === 'ask') {
|
|
1306
|
-
degrades.push(`fs_outside_repo=ask requested, but this render expresses no prompt-on-outside-write mode (${observedPhrase(harness)}) — rendered as the deny form (writes hard-confined to cwd+$TMPDIR; an outside write is blocked, then auto-retried through the normal permission flow).`);
|
|
1324
|
+
degrades.push(`fs_outside_repo=ask requested, but this render expresses no prompt-on-outside-write mode (${observedPhrase(harness)}) — rendered as the deny form (${HOST_HONORS_QUALIFIER}, writes hard-confined to cwd+$TMPDIR; an outside write is blocked, then auto-retried through the normal permission flow).`);
|
|
1307
1325
|
} else {
|
|
1308
|
-
notes.push(
|
|
1326
|
+
notes.push(`fs_outside_repo=deny → ${HOST_HONORS_QUALIFIER}, writes confined to cwd+$TMPDIR (the sandbox default).`);
|
|
1309
1327
|
}
|
|
1310
1328
|
// sandbox availability (Step 3.3 probe) — a LOUD degrade where the OS can't sandbox; the red-lines +
|
|
1311
1329
|
// defaultMode still land (they are permission rules, sandbox-independent).
|
|
1312
1330
|
if (!probe.available) {
|
|
1313
|
-
degrades.push(`sandbox UNAVAILABLE on this host (${probe.reason}) — claude renders the sandbox block but WARNS and runs UNSANDBOXED: ad-hoc scripts will still PROMPT and
|
|
1331
|
+
degrades.push(`sandbox UNAVAILABLE on this host (${probe.reason}) — ${HOST_HONORS_QUALIFIER}, claude renders the sandbox block but WARNS and runs UNSANDBOXED: ad-hoc scripts will still PROMPT and the confinement THIS RENDER asks for is not enforced until it is available (run /agent-workflow-kit autonomy-doctor to diagnose and, with your consent, install the missing dependency). The red-lines + defaultMode still apply. failIfUnavailable is left UNSET so the session is never bricked.`);
|
|
1314
1332
|
}
|
|
1315
1333
|
return { level, activities: resolved.activities, sandbox, defaultMode, ask, deny, notes, degrades };
|
|
1316
1334
|
};
|
|
@@ -1389,7 +1407,28 @@ const collectRedlineBypass = (sources) =>
|
|
|
1389
1407
|
// escape the sandbox entirely. The render owns only enabled/autoAllow and preserves other sandbox
|
|
1390
1408
|
// sub-keys (merge-don't-clobber, never a silent clobber of the user's sandbox tuning), so a pre-existing
|
|
1391
1409
|
// weakening sub-key is REPORTED loudly (remove it by hand) — never silently carried as security.
|
|
1392
|
-
|
|
1410
|
+
// The allowWrite degrade's own half: an entry is only an `fs_outside_repo` weakening once it
|
|
1411
|
+
// RESOLVES outside the boundaries a red-line already allows (the repo itself and $TMPDIR — the
|
|
1412
|
+
// autonomy policy's own scratch surface). Resolution comes FIRST, then the surviving entries are
|
|
1413
|
+
// NAMED by their resolved path: reporting a bare count declared every entry external and told the
|
|
1414
|
+
// maintainer nothing about which one to remove. A path is rendered JSON-quoted, so one carrying
|
|
1415
|
+
// spaces, shell metacharacters or a newline still renders on ONE line.
|
|
1416
|
+
// An entry that is not a resolvable string cannot be judged — and must never be DROPPED into
|
|
1417
|
+
// silence: it is counted as unresolvable, so a malformed declaration stays loud instead of quietly
|
|
1418
|
+
// emptying the degrade. A blank entry belongs to that class rather than resolving to the repo root
|
|
1419
|
+
// and disappearing as "contained".
|
|
1420
|
+
const externalWriteEntries = (entries, { root, home, tmp }) => {
|
|
1421
|
+
const boundaries = [resolve(root), ...(tmp ? [resolve(tmp)] : [])];
|
|
1422
|
+
const resolvable = entries.filter(isResolvableDeclaredEntry);
|
|
1423
|
+
return {
|
|
1424
|
+
external: resolvable
|
|
1425
|
+
.map((entry) => resolveDeclaredDir(entry, { home, root }))
|
|
1426
|
+
.filter((resolved) => !boundaries.some((boundary) => dirCovers(boundary, resolved))),
|
|
1427
|
+
unresolvable: entries.length - resolvable.length,
|
|
1428
|
+
};
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
const collectSandboxWeakenings = (sources, { root, home, tmp }) => {
|
|
1393
1432
|
// Tier-known PROOF: an excludedCommands entry is downgraded to a note ONLY when it is
|
|
1394
1433
|
// demonstrably the consented tier's own output — it lives in the PROJECT settings.json (the file
|
|
1395
1434
|
// the tier writes; a local-file exclusion is never tier output) AND the matching derived
|
|
@@ -1405,14 +1444,34 @@ const collectSandboxWeakenings = (sources) => {
|
|
|
1405
1444
|
const out = [];
|
|
1406
1445
|
const net = isJsonObject(sb.network) ? sb.network : {};
|
|
1407
1446
|
if (Array.isArray(net.allowedDomains) && net.allowedDomains.length) {
|
|
1408
|
-
out.push({ source, key: `${SANDBOX_KEY}.network.allowedDomains`, weakens: 'network', detail: `${net.allowedDomains.length} pre-allowed domain(s) — egress to them is not gated` });
|
|
1447
|
+
out.push({ source, key: `${SANDBOX_KEY}.network.allowedDomains`, weakens: 'network', detail: `${net.allowedDomains.length} pre-allowed domain(s) — ${HOST_HONORS_QUALIFIER}, egress to them is not gated` });
|
|
1409
1448
|
}
|
|
1410
1449
|
const fsb = isJsonObject(sb.filesystem) ? sb.filesystem : {};
|
|
1411
|
-
|
|
1412
|
-
|
|
1450
|
+
// A PRESENT but unreadable declaration is reported, never assumed empty: what it would make
|
|
1451
|
+
// writable is unknown, and silence there is the same defect as an over-report.
|
|
1452
|
+
if (fsb.allowWrite !== undefined && !Array.isArray(fsb.allowWrite)) {
|
|
1453
|
+
// UNVERIFIABLE, not "weakening": an unreadable value cannot be claimed to widen anything (a
|
|
1454
|
+
// host may reject the whole key). Its own class, so the render states what it cannot verify
|
|
1455
|
+
// instead of asserting an effect it does not know.
|
|
1456
|
+
out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', unverifiable: true, detail: `the declared value is not an array (${typeof fsb.allowWrite}) — it cannot be read, so what it would make writable is UNKNOWN` });
|
|
1457
|
+
} else if (Array.isArray(fsb.allowWrite) && fsb.allowWrite.length) {
|
|
1458
|
+
// Only the entries that RESOLVE outside the repo and $TMPDIR are a weakening at all — an entry
|
|
1459
|
+
// pointing INSIDE the repo grants nothing the red-line withholds, so reporting it would be an
|
|
1460
|
+
// over-report. All-contained ⇒ no line at all.
|
|
1461
|
+
// TWO records, never one: a resolved external path IS a weakening, an unresolvable entry is
|
|
1462
|
+
// UNVERIFIABLE, and a single line carrying both would assert an effect for entries it could
|
|
1463
|
+
// not read — the contradiction this phase exists to remove. An array of only-unresolvable
|
|
1464
|
+
// entries therefore yields no weakening claim at all.
|
|
1465
|
+
const { external, unresolvable } = externalWriteEntries(fsb.allowWrite, { root, home, tmp });
|
|
1466
|
+
if (external.length) {
|
|
1467
|
+
out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', detail: `${HOST_HONORS_QUALIFIER}, ${external.length} declared path(s) resolve OUTSIDE the repo and $TMPDIR and are writable: ${external.map((p) => JSON.stringify(p)).join(', ')}` });
|
|
1468
|
+
}
|
|
1469
|
+
if (unresolvable) {
|
|
1470
|
+
out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', unverifiable: true, detail: `${unresolvable} declared entr(ies) could not be resolved (not a non-empty string) — what they would make writable is UNKNOWN` });
|
|
1471
|
+
}
|
|
1413
1472
|
}
|
|
1414
1473
|
if (sb.allowUnsandboxedCommands === true) {
|
|
1415
|
-
out.push({ source, key: `${SANDBOX_KEY}.allowUnsandboxedCommands`, weakens: 'every sandbox red-line', detail:
|
|
1474
|
+
out.push({ source, key: `${SANDBOX_KEY}.allowUnsandboxedCommands`, weakens: 'every sandbox red-line', detail: `${HOST_HONORS_QUALIFIER}, commands may run unsandboxed` });
|
|
1416
1475
|
}
|
|
1417
1476
|
if (Array.isArray(sb.excludedCommands) && sb.excludedCommands.length) {
|
|
1418
1477
|
// The bridge tier's OWN wrapper names are tier-known ONLY with the proof above (the
|
|
@@ -1423,9 +1482,9 @@ const collectSandboxWeakenings = (sources) => {
|
|
|
1423
1482
|
const tierKnown = sb.excludedCommands.filter((c) => isTierKnownExclusion(source, c));
|
|
1424
1483
|
const foreign = sb.excludedCommands.filter((c) => !tierKnown.includes(c));
|
|
1425
1484
|
if (foreign.length) {
|
|
1426
|
-
out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: 'every sandbox red-line', detail: `${foreign.length} command(s) run UNSANDBOXED (network/fs confinement not applied to them)${tierKnown.length ? `; ${tierKnown.length} bridge-review wrapper exclusion(s) are tier-known and not flagged` : ''}` });
|
|
1485
|
+
out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: 'every sandbox red-line', detail: `${foreign.length} command(s) run UNSANDBOXED ${HOST_HONORS_QUALIFIER} (network/fs confinement not applied to them)${tierKnown.length ? `; ${tierKnown.length} bridge-review wrapper exclusion(s) are tier-known and not flagged` : ''}` });
|
|
1427
1486
|
} else if (tierKnown.length) {
|
|
1428
|
-
out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: null, tierKnown: true, detail: `${tierKnown.length} bridge-review wrapper exclusion(s) (${tierKnown.join(', ')}) — tier-known: the consented bridge-wrappers tier runs them outside the sandbox (network), and its allow rules are present in the project settings` });
|
|
1487
|
+
out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: null, tierKnown: true, detail: `${tierKnown.length} bridge-review wrapper exclusion(s) (${tierKnown.join(', ')}) — tier-known: ${HOST_HONORS_QUALIFIER}, the consented bridge-wrappers tier runs them outside the sandbox (network), and its allow rules are present in the project settings` });
|
|
1429
1488
|
}
|
|
1430
1489
|
}
|
|
1431
1490
|
return out;
|
|
@@ -1472,10 +1531,10 @@ export const formatAutonomyResult = (r) => {
|
|
|
1472
1531
|
`policy: ${r.source} · per-activity: ${formatActivityLevels(r.activities)} · effective global autonomy: ${r.level}`,
|
|
1473
1532
|
];
|
|
1474
1533
|
if (r.level === AUTONOMY_PROMPT && !Object.values(r.activities).every((v) => v.autonomy === AUTONOMY_PROMPT)) {
|
|
1475
|
-
lines.push(
|
|
1534
|
+
lines.push(` note: global autonomy is \`prompt\` because not every activity is \`sandbox\` — set every activity to sandbox (set-autonomy) to enable global auto-allow (conservative unanimity; the sandbox still confines ${HOST_HONORS_QUALIFIER}).`);
|
|
1476
1535
|
}
|
|
1477
1536
|
lines.push(
|
|
1478
|
-
`sandbox: ${SANDBOX_ENABLED_KEY}=true, ${SANDBOX_AUTOALLOW_KEY}=${r.sandbox[SANDBOX_AUTOALLOW_KEY]} (${r.level === AUTONOMY_SANDBOX ? 'auto-allow confined commands' : 'auto-allow OFF — confine only'})`,
|
|
1537
|
+
`sandbox: ${SANDBOX_ENABLED_KEY}=true, ${SANDBOX_AUTOALLOW_KEY}=${r.sandbox[SANDBOX_AUTOALLOW_KEY]} (${r.level === AUTONOMY_SANDBOX ? 'auto-allow confined commands' : 'auto-allow OFF — confine only'}, ${HOST_HONORS_QUALIFIER})`,
|
|
1479
1538
|
`permissions.${DEFAULT_MODE_KEY}: ${r.wrote ? 'set to' : 'would set to'} ${r.defaultMode}`,
|
|
1480
1539
|
`permissions.ask (render-owned red-lines): ${r.ask.length ? r.ask.join(', ') : '(none)'}`,
|
|
1481
1540
|
`permissions.deny (render-owned red-lines): ${r.deny.length ? r.deny.join(', ') : '(none)'}`,
|
|
@@ -1484,15 +1543,26 @@ export const formatAutonomyResult = (r) => {
|
|
|
1484
1543
|
for (const n of r.notes) lines.push(` note: ${n}`);
|
|
1485
1544
|
for (const d of r.degrades) lines.push(` ⚠ DEGRADE: ${d}`);
|
|
1486
1545
|
for (const m of r.localMasks ?? []) {
|
|
1487
|
-
|
|
1546
|
+
// "the local value wins" is a claim about a settings key's runtime effect wherever the masked key
|
|
1547
|
+
// is a sandbox one — on a host that ignores sandbox.* NEITHER value takes effect. The scope
|
|
1548
|
+
// boundary is deliberate: permissions.* precedence is the harness's own documented permission
|
|
1549
|
+
// model (the layer this whole mode rests on), so those masks stay flat statements.
|
|
1550
|
+
const scoped = m.key.startsWith(`${SANDBOX_KEY}.`) ? ` ${HOST_HONORS_QUALIFIER}` : '';
|
|
1551
|
+
lines.push(` ⚠ ${SETTINGS_LOCAL_FILE} sets ${m.key}=${JSON.stringify(m.local)}, which MASKS this render's ${m.key}=${JSON.stringify(m.rendered)} (local > project) — the local value wins${scoped}, so the render is not effective for that key; the local file is the maintainer's and is never written by the kit.`);
|
|
1488
1552
|
}
|
|
1489
1553
|
for (const b of r.redlineBypass ?? []) {
|
|
1490
1554
|
lines.push(` ⚠ DEGRADE: ${b.source} has a pre-existing allow entry ${b.entry} that would BYPASS the rendered red-line(s) ${b.redlines.join('/')} (a matching allow rule AUTO-APPROVES the command, defeating ask/deny) — remove it by hand; this render never touches permissions.allow.`);
|
|
1491
1555
|
}
|
|
1492
1556
|
for (const w of r.sandboxWeakenings ?? []) {
|
|
1493
1557
|
if (w.tierKnown) lines.push(` note: ${w.source} has ${w.key} (${w.detail}).`);
|
|
1558
|
+
else if (w.unverifiable) lines.push(` ⚠ DEGRADE: ${w.source} has ${w.key} (${w.detail}) — the rendered ${w.weakens} red-line CANNOT BE VERIFIED against it (no claim either way); fix the declared value by hand, then re-run this preview.`);
|
|
1494
1559
|
else lines.push(` ⚠ DEGRADE: ${w.source} has ${w.key} (${w.detail}), which WEAKENS the rendered ${w.weakens} red-line — the render preserves your sandbox tuning (never clobbers it), so remove it by hand if you want the red-line fully enforced.`);
|
|
1495
1560
|
}
|
|
1561
|
+
// The notice is UNCONDITIONAL: every autonomy render asserts a settings-key runtime effect (the
|
|
1562
|
+
// sandbox line and its notes at minimum), so a clean deployment would otherwise read those as
|
|
1563
|
+
// promises. It names the unknown once for the whole render; the per-line qualifiers above carry it
|
|
1564
|
+
// for a line quoted in isolation.
|
|
1565
|
+
lines.push(` ${HOST_HONORS_NOTICE}`);
|
|
1496
1566
|
lines.push(AUTONOMY_RESIDUAL_NOTICE);
|
|
1497
1567
|
if (!r.wrote) lines.push(`re-run with ${FLAG_APPLY} to write .claude/settings.json (only the render-owned blocks change).`);
|
|
1498
1568
|
return lines.join(LF);
|
|
@@ -1530,7 +1600,13 @@ export const writeAutonomyProfile = ({ cwd, apply = false } = {}, deps = {}) =>
|
|
|
1530
1600
|
{ source: SETTINGS_LOCAL_FILE, data: preflight.localSettings?.data },
|
|
1531
1601
|
];
|
|
1532
1602
|
const redlineBypass = collectRedlineBypass(settingsSources);
|
|
1533
|
-
|
|
1603
|
+
// The allowWrite degrade resolves its entries before judging them, so it needs the same anchors a
|
|
1604
|
+
// host resolving the key would use: the project root, the resolved home, and $TMPDIR.
|
|
1605
|
+
const sandboxWeakenings = collectSandboxWeakenings(settingsSources, {
|
|
1606
|
+
root: projectDir,
|
|
1607
|
+
home: deps.home ?? homedir(),
|
|
1608
|
+
tmp: (deps.env ?? process.env).TMPDIR ?? tmpdir(),
|
|
1609
|
+
});
|
|
1534
1610
|
const resultBase = {
|
|
1535
1611
|
autonomy: true,
|
|
1536
1612
|
source,
|
|
@@ -1631,6 +1707,9 @@ export const formatAutonomyCheck = (c) =>
|
|
|
1631
1707
|
? [
|
|
1632
1708
|
`autonomy --check: IN SYNC — ${SETTINGS_FILE} matches the ${c.source} render (level ${c.level}).`,
|
|
1633
1709
|
...(c.degrades ?? []).map((d) => ` ⚠ DEGRADE: ${d}`),
|
|
1710
|
+
// The gate surface carries the same unknown as the preview whenever it states one of these
|
|
1711
|
+
// effects; a --check read in isolation must not be the one place the promise reads flat.
|
|
1712
|
+
...((c.degrades ?? []).length ? [` ${HOST_HONORS_NOTICE}`] : []),
|
|
1634
1713
|
].join('\n')
|
|
1635
1714
|
: [
|
|
1636
1715
|
`autonomy --check: DRIFT — ${SETTINGS_FILE} diverges from the ${c.source} render (level ${c.level}):`,
|