@sabaiway/agent-workflow-kit 3.1.0 → 3.3.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/SKILL.md +3 -3
  3. package/bridges/antigravity-cli-bridge/SKILL.md +6 -4
  4. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +84 -7
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +113 -0
  6. package/bridges/antigravity-cli-bridge/bin/agy.sh +55 -6
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
  8. package/bridges/antigravity-cli-bridge/capability.json +4 -2
  9. package/bridges/antigravity-cli-bridge/references/driving-agy.md +5 -0
  10. package/bridges/codex-cli-bridge/SKILL.md +8 -4
  11. package/bridges/codex-cli-bridge/bin/codex-exec.sh +129 -11
  12. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +211 -1
  13. package/bridges/codex-cli-bridge/bin/codex-review.sh +76 -13
  14. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +47 -0
  15. package/bridges/codex-cli-bridge/capability.json +19 -4
  16. package/bridges/codex-cli-bridge/references/driving-codex.md +11 -0
  17. package/capability.json +1 -1
  18. package/package.json +1 -1
  19. package/references/modes/bootstrap.md +3 -2
  20. package/references/modes/upgrade.md +9 -4
  21. package/references/modes/velocity.md +8 -2
  22. package/references/shared/command-shapes.md +22 -0
  23. package/references/shared/composition-handoff.md +7 -2
  24. package/references/templates/agent_rules.md +10 -1
  25. package/tools/bridge-settings-read.mjs +25 -5
  26. package/tools/delegation.mjs +2 -2
  27. package/tools/detect-backends.mjs +10 -0
  28. package/tools/lens-region.mjs +113 -36
  29. package/tools/recipes.mjs +5 -2
  30. package/tools/release-scan.mjs +147 -13
  31. package/tools/run-gates.mjs +43 -2
  32. package/tools/velocity-profile.mjs +229 -20
@@ -1,5 +1,5 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
- import { join, relative, resolve } from 'node:path';
2
+ import { basename, dirname, join, relative, resolve } from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
  // The --autonomy render reads the per-project autonomy policy through the read-only autonomy core
5
5
  // (AD-044). This file is the family's one .claude/settings.json writer, so the policy render lives here;
@@ -9,6 +9,7 @@ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from '.
9
9
  // The bridge-wrappers tier's placement probe (AD-044 Plan 4, Decision 2): a tier entry derives ONLY
10
10
  // for a PLACED bridge wrapper — findOnPath is the same read-only PATH scan the backend detector uses.
11
11
  import { findOnPath } from './detect-backends.mjs';
12
+ import { compareSemver } from './semver-lite.mjs';
12
13
 
13
14
  // Velocity-profile core + writer: a fixed, audited read-only allowlist that an onboarding step seeds
14
15
  // into `.claude/settings.json` so routine read-only commands stop idling on approval prompts.
@@ -982,12 +983,12 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
982
983
  // ── the --autonomy render (AD-044) ───────────────────────────────────────────────────────────────
983
984
  // Render docs/ai/autonomy.json into the .claude/settings.json blocks the render OWNS — the `sandbox`
984
985
  // block + permissions.ask/deny red-lines + permissions.defaultMode — per the Decision-6 invariant,
985
- // wired to what the REAL claude 2.1.185 accepts (Step 3.1). POLICY-ONLY: never seeds the read-only
986
+ // wired to what the installed harness accepts, READ AT RUN TIME. POLICY-ONLY: never seeds the read-only
986
987
  // allowlist and never touches permissions.allow as a value; a sibling of the flagless allowlist merge
987
988
  // (mergeProjectSettings) that follows the identical merge-don't-clobber discipline. Preview-then-apply,
988
989
  // dry-run default. Refuses an absent policy (a writer renders only a declared policy).
989
990
 
990
- // The exact 2.1.185 sandbox settings keys (Step 3.1, characterized against the real CLI + official docs).
991
+ // The sandbox settings keys this render owns (characterized against the real CLI + official docs).
991
992
  const SANDBOX_KEY = 'sandbox';
992
993
  const SANDBOX_ENABLED_KEY = 'enabled';
993
994
  const SANDBOX_AUTOALLOW_KEY = 'autoAllowBashIfSandboxed';
@@ -1075,6 +1076,153 @@ export const probeSandboxAvailability = (deps = {}) => {
1075
1076
  return { platform, sandbox: 'none', available: false, missing: [], reason: `native ${platform} sandbox unsupported — use WSL2 on Windows` };
1076
1077
  };
1077
1078
 
1079
+ // ── the harness-version probe ────────────────────────────────────────────────────────────────────
1080
+ // A version literal frozen in code is a documentation claim: it goes stale by default and nothing
1081
+ // reports it. Nobody can guarantee another vendor's version format, install layout or settings
1082
+ // shape, so this probe does NOT promise a correct answer — it promises that being wrong is LOUD.
1083
+ // A pin goes stale silently; a probe goes stale with a stated unknown. That direction-of-failure IS
1084
+ // the whole value. Read-only (PATH scan + realpath via findOnPath), never a spawn — the same
1085
+ // doctrine probeSandboxAvailability follows.
1086
+ const HARNESS_BIN = 'claude';
1087
+ // Two install layouts, both read-only, both matched EXACTLY. The native installer puts the binary at
1088
+ // …/claude/versions/<semver>; an npm install carries the @anthropic-ai/claude-code package.json a few
1089
+ // levels up. Identity is checked, never guessed from a substring: a third-party wrapper called
1090
+ // my-claude-wrapper@9.0.0 must resolve to UNKNOWN, because a false "supported" renders a protection
1091
+ // that is not there — strictly worse than an honest unknown, which at least degrades loudly.
1092
+ const HARNESS_PKG_NAME = '@anthropic-ai/claude-code';
1093
+ const HARNESS_VERSIONS_DIR = 'versions';
1094
+ const HARNESS_PKG_SEARCH_DEPTH = 5;
1095
+ // The ONLY fs errors that mean "keep walking". EACCES is deliberately NOT here: an unreadable
1096
+ // package.json means we cannot confirm, which must surface rather than read as "not the harness".
1097
+ // A coded TypeError (Node attaches codes like ERR_INVALID_ARG_TYPE to programming defects) must not
1098
+ // pass as an expected walk failure — that is how a defect would masquerade as an unknown layout.
1099
+ const WALK_SKIP_FS_CODES = Object.freeze(['ENOENT', 'ENOTDIR', 'EISDIR', 'ELOOP', 'ENAMETOOLONG']);
1100
+ // Strict: the whole segment must be a version, so a decorated or trailing-garbage name is unknown
1101
+ // rather than a prefix-parsed guess.
1102
+ const STRICT_SEMVER_RE = /^\d+\.\d+\.\d+$/;
1103
+
1104
+ const unknownHarness = (reason) => ({ version: null, source: null, reason });
1105
+
1106
+ const ancestorDirs = (start, depth) => {
1107
+ const step = (acc) => {
1108
+ const last = acc[acc.length - 1];
1109
+ const parent = dirname(last);
1110
+ return acc.length >= depth || parent === last ? acc : step([...acc, parent]);
1111
+ };
1112
+ return step([start]);
1113
+ };
1114
+
1115
+ // The native layout, matched in FULL: …/claude/versions/<version>. Checking only the `versions/`
1116
+ // parent would accept /opt/my-wrapper/versions/9.0.0 as the official harness, so the grandparent is
1117
+ // checked too, and the version segment must match strictly — a prefix parse would read
1118
+ // `2.1.215-tampered` as a clean version.
1119
+ const harnessVersionFromNativeLayout = (installPath) => {
1120
+ const versionsDir = dirname(installPath);
1121
+ const isNativeLayout = basename(versionsDir) === HARNESS_VERSIONS_DIR && basename(dirname(versionsDir)) === HARNESS_BIN;
1122
+ const segment = basename(installPath);
1123
+ return isNativeLayout && STRICT_SEMVER_RE.test(segment) ? segment : null;
1124
+ };
1125
+
1126
+ // A walk failure is EXPECTED (no package.json at that ancestor, or a malformed one) and is skipped;
1127
+ // every other error class rethrows. A probe that swallowed a programming defect would report
1128
+ // "unrecognised install layout" — a wrong reason, which is the exact defect this feature exists to
1129
+ // stop, just relocated into the probe itself.
1130
+ const readPackageVersionAt = (dir, readFile) => {
1131
+ try {
1132
+ const pkg = JSON.parse(readFile(join(dir, 'package.json'), UTF8));
1133
+ // Strict, like the native layout: a prefix parse would read `2.1.187-beta.0` as the stable
1134
+ // `2.1.187` and switch a capability on for a build that may not have it. A prerelease is an
1135
+ // honest unknown, not a rounding-down opportunity.
1136
+ return pkg.name === HARNESS_PKG_NAME && STRICT_SEMVER_RE.test(String(pkg.version)) ? String(pkg.version) : null;
1137
+ } catch (err) {
1138
+ if (err instanceof SyntaxError) return null;
1139
+ if (err instanceof TypeError || err instanceof RangeError || err instanceof ReferenceError) throw err;
1140
+ if (err && WALK_SKIP_FS_CODES.includes(err.code)) return null;
1141
+ throw err;
1142
+ }
1143
+ };
1144
+
1145
+ // Short-circuits at the first confirmed package: an eager map would keep walking after the answer
1146
+ // was already found, so an unrelated ancestor's EACCES would abort a probe that had SUCCEEDED.
1147
+ // Fail-loud on an unreadable ancestor still applies for every dir visited BEFORE the confirmation.
1148
+ const harnessVersionFromPackageJson = (installPath, readFile) => {
1149
+ for (const dir of ancestorDirs(dirname(installPath), HARNESS_PKG_SEARCH_DEPTH)) {
1150
+ const version = readPackageVersionAt(dir, readFile);
1151
+ if (version !== null) return version;
1152
+ }
1153
+ return null;
1154
+ };
1155
+
1156
+ // probeHarnessVersion(deps) → { version, source, reason }. `version` is null whenever the installed
1157
+ // build could not be read; `reason` then STATES why, so a caller can say "unknown" instead of
1158
+ // inventing either direction.
1159
+ export const probeHarnessVersion = (deps = {}) => {
1160
+ const locate = deps.findOnPath ?? findOnPath;
1161
+ const readFile = deps.readFile ?? readFileSync;
1162
+ const found = locate(HARNESS_BIN, deps);
1163
+ if (!found.path) {
1164
+ return unknownHarness(`no ${HARNESS_BIN} binary resolved on PATH (${found.state}) — the installed harness version is unknown`);
1165
+ }
1166
+ const version = harnessVersionFromNativeLayout(found.path) ?? harnessVersionFromPackageJson(found.path, readFile);
1167
+ return version === null
1168
+ ? unknownHarness(`resolved ${HARNESS_BIN} to ${found.path}, but no version could be read from that install layout — the installed harness version is unknown`)
1169
+ : { version, source: found.path, reason: `read from the resolved ${HARNESS_BIN} install path ${found.path}` };
1170
+ };
1171
+
1172
+ // The capability threshold: sandbox credential denial arrived here. This literal is HISTORY (the
1173
+ // release where the key appeared), never a claim about the installed build — it is only ever
1174
+ // compared against a version the probe OBSERVED, which is what keeps it honest.
1175
+ const CREDENTIALS_DENY_SINCE = '2.1.187';
1176
+ const CREDENTIALS_KEY = 'credentials';
1177
+ const CREDENTIAL_DENY_MODE = 'deny';
1178
+ // The env vars the profile protects when the installed build supports it. File-based credentials
1179
+ // (~/.ssh) are deliberately NOT rendered: the entry shape for sandbox.credentials.files was not
1180
+ // verified against an installed build, and this profile never renders what it has not observed.
1181
+ const PROTECTED_ENV_VARS = Object.freeze(['NPM_TOKEN', 'GITHUB_TOKEN']);
1182
+ // A caller that did not probe gets the same treatment as a failed probe — a stated unknown. The
1183
+ // default must never be an assumed capability in either direction.
1184
+ const HARNESS_UNPROBED = Object.freeze({
1185
+ version: null,
1186
+ source: null,
1187
+ reason: 'the harness version was not probed by this caller',
1188
+ });
1189
+
1190
+ // Inside the credentials block this render owns EXACTLY the envVars entries for the names it
1191
+ // protects. Everything else — `files`, `allowPlaintextInject`, any other envVar — is the
1192
+ // maintainer's, and the degrade explicitly invites them to declare it. So the block follows the same
1193
+ // merge-don't-clobber contract as the rest of the sandbox key, and drift compares only the owned
1194
+ // slice: a maintainer who takes the tool's own advice must never be told their settings drifted.
1195
+ const isOwnedEnvVar = (entry) => isJsonObject(entry) && PROTECTED_ENV_VARS.includes(entry.name);
1196
+
1197
+ // Canonical projection: same keys, same order, sorted by name. Comparing raw entries would flag a
1198
+ // hand-written {mode, name} as drift against an identical {name, mode} — a warning about nothing,
1199
+ // which trains the reader to ignore the loud channel this whole feature depends on.
1200
+ const ownedEnvVarsIn = (block) => {
1201
+ const entries = isJsonObject(block) && Array.isArray(block.envVars) ? block.envVars : [];
1202
+ return entries
1203
+ .filter(isOwnedEnvVar)
1204
+ .map((entry) => ({ name: entry.name, mode: entry.mode }))
1205
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)));
1206
+ };
1207
+
1208
+ const mergeCredentialsBlock = (existing, rendered) => {
1209
+ const base = isJsonObject(existing) ? existing : {};
1210
+ const foreignEnvVars = (Array.isArray(base.envVars) ? base.envVars : []).filter((e) => !isOwnedEnvVar(e));
1211
+ if (rendered !== undefined) return { ...base, envVars: [...foreignEnvVars, ...rendered.envVars] };
1212
+ const withoutOwned = { ...base };
1213
+ if (foreignEnvVars.length) withoutOwned.envVars = foreignEnvVars;
1214
+ else delete withoutOwned.envVars;
1215
+ return Object.keys(withoutOwned).length > 0 ? withoutOwned : undefined;
1216
+ };
1217
+
1218
+ const observedPhrase = (harness) =>
1219
+ harness.version !== null
1220
+ ? `observed ${HARNESS_BIN} ${harness.version}`
1221
+ : `the installed ${HARNESS_BIN} version could not be determined — ${harness.reason}`;
1222
+
1223
+ const supportsCredentialDenial = (harness) =>
1224
+ harness.version !== null && compareSemver(harness.version, CREDENTIALS_DENY_SINCE) >= 0;
1225
+
1078
1226
  // effectiveAutonomyLevel(resolved) → the ONE global level the (global, static) settings file renders.
1079
1227
  // The autonomy policy is per-activity (Decision 2), but .claude/settings.json is global and the
1080
1228
  // plan.end/exec.end checkpoints are BEHAVIORAL, not machine-enforced (Decision 1). Global auto-allow is
@@ -1086,15 +1234,22 @@ export const effectiveAutonomyLevel = (resolved) => {
1086
1234
  return levels.length > 0 && levels.every((l) => l === AUTONOMY_SANDBOX) ? AUTONOMY_SANDBOX : AUTONOMY_PROMPT;
1087
1235
  };
1088
1236
 
1089
- // renderAutonomySettings(resolved, probe) → the render-owned blocks + honesty notes/degrades. PURE. The
1237
+ // renderAutonomySettings(resolved, probe, harness) → the render-owned blocks + honesty notes/degrades. PURE. The
1090
1238
  // sandbox is ALWAYS enabled (the Decision-1 floor: BOTH levels confine); auto-allow + defaultMode follow
1091
1239
  // the collapsed level. Command red-lines land under ask/deny by value. The three non-command red-lines
1092
- // are the sandbox DEFAULTS (network prompt-on-egress; fs cwd+$TMPDIR confine) — where 2.1.185 cannot
1240
+ // are the sandbox DEFAULTS (network prompt-on-egress; fs cwd+$TMPDIR confine) — where this render cannot
1093
1241
  // express a value distinctly it DEGRADES LOUDLY (never a silent allow, never silent pretend-security).
1094
- export const renderAutonomySettings = (resolved, probe) => {
1242
+ export const renderAutonomySettings = (resolved, probe, harness = HARNESS_UNPROBED) => {
1095
1243
  const level = effectiveAutonomyLevel(resolved);
1096
1244
  const wantsSandbox = level === AUTONOMY_SANDBOX;
1097
- const sandbox = { [SANDBOX_ENABLED_KEY]: true, [SANDBOX_AUTOALLOW_KEY]: wantsSandbox };
1245
+ const credentialsSupported = supportsCredentialDenial(harness);
1246
+ const sandbox = {
1247
+ [SANDBOX_ENABLED_KEY]: true,
1248
+ [SANDBOX_AUTOALLOW_KEY]: wantsSandbox,
1249
+ ...(credentialsSupported
1250
+ ? { [CREDENTIALS_KEY]: { envVars: PROTECTED_ENV_VARS.map((name) => ({ name, mode: CREDENTIAL_DENY_MODE })) } }
1251
+ : {}),
1252
+ };
1098
1253
  const defaultMode = DEFAULT_MODE_FOR[level];
1099
1254
  const ask = [];
1100
1255
  const deny = [];
@@ -1103,20 +1258,34 @@ export const renderAutonomySettings = (resolved, probe) => {
1103
1258
  }
1104
1259
  const notes = [];
1105
1260
  const degrades = [];
1106
- // network — 2.1.185 regular settings cannot HARD-BLOCK egress; both values render as no allowedDomains
1107
- // (prompt-on-egress). deny DEGRADES LOUDLY (can't hard-block); ask is the faithful sandbox default.
1261
+ // network — this render owns no egress-blocking key, so both values land as prompt-on-egress. The
1262
+ // degrade states what THIS RENDER does not express; it never claims a platform limit it did not
1263
+ // observe, and it names the version it did observe (or states the unknown).
1108
1264
  if (resolved.redlines.network === 'deny') {
1109
- degrades.push('network=deny requested, but claude 2.1.185 regular settings cannot HARD-BLOCK egress — 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).');
1265
+ 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).`);
1110
1266
  } else {
1111
1267
  notes.push('network=ask → prompt on each new domain (the sandbox default; no domains pre-allowed).');
1112
1268
  }
1113
- // credentials — 2.1.185 has NO sandbox.credentials at all (deny added 2.1.187, mask 2.1.199). BOTH
1114
- // values degrade loudly never silent pretend-security.
1115
- degrades.push(`credentials=${resolved.redlines.credentials} requested, but claude 2.1.185 has NO sandbox credential denial (deny added in 2.1.187, mask in 2.1.199) — NPM_TOKEN/GITHUB_TOKEN and ~/.ssh are NOT hidden from sandboxed commands. Upgrade to 2.1.187+ for sandbox.credentials.`);
1116
- // fs_outside_repo — the sandbox default is a HARD confine to cwd+$TMPDIR; 2.1.185 has no
1117
- // prompt-on-outside-write, so `ask` DEGRADES LOUDLY to the deny form (hard confine).
1269
+ // credentials — rendered when the OBSERVED version reaches the threshold, degraded loudly when it
1270
+ // does not or when the version is unknown. Reporting a platform limit that does not exist is the
1271
+ // defect; so is pretending protection that was never confirmed.
1272
+ if (credentialsSupported) {
1273
+ // The installed schema offers deny|mask — there is NO ask mode, so `ask` cannot be expressed and
1274
+ // DEGRADES LOUDLY to the deny form rather than being quietly upgraded. And even on deny the
1275
+ // coverage is PARTIAL: env vars only, because the files entry shape was never verified against an
1276
+ // installed build. Partial protection reported as success is the same defect as the false claim
1277
+ // this render replaced — so it degrades too, never a note.
1278
+ if (resolved.redlines.credentials === 'ask') {
1279
+ 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.`);
1280
+ }
1281
+ 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.`);
1282
+ } else {
1283
+ 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 NOT hidden from sandboxed commands here. Upgrade to ${CREDENTIALS_DENY_SINCE}+ (or fix the install so the version can be read) for sandbox.${CREDENTIALS_KEY}.`);
1284
+ }
1285
+ // fs_outside_repo — the sandbox default is a HARD confine to cwd+$TMPDIR; this render expresses no
1286
+ // prompt-on-outside-write mode, so `ask` DEGRADES LOUDLY to the deny form (hard confine).
1118
1287
  if (resolved.redlines.fs_outside_repo === 'ask') {
1119
- degrades.push('fs_outside_repo=ask requested, but claude 2.1.185 has no prompt-on-outside-write — rendered as the deny form (writes hard-confined to cwd+$TMPDIR; an outside write is blocked, then auto-retried through the normal permission flow).');
1288
+ 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).`);
1120
1289
  } else {
1121
1290
  notes.push('fs_outside_repo=deny → writes confined to cwd+$TMPDIR (the sandbox default).');
1122
1291
  }
@@ -1147,7 +1316,14 @@ export const mergeAutonomySettings = (projectData, render) => {
1147
1316
  else delete mergedPermissions.ask;
1148
1317
  if (mergedDeny.length) mergedPermissions.deny = mergedDeny;
1149
1318
  else delete mergedPermissions.deny;
1319
+ // The credentials block is render-OWNED like the two flags above: it must be written when the
1320
+ // render carries it and REMOVED when it does not, or a stale block would outlive the capability
1321
+ // that justified it. A render that carries no credentials key protects nothing, so leaving a
1322
+ // previous one in place would keep claiming a protection this run did not render.
1323
+ const mergedCredentials = mergeCredentialsBlock(existingSandbox[CREDENTIALS_KEY], render.sandbox[CREDENTIALS_KEY]);
1150
1324
  const mergedSandbox = { ...existingSandbox, [SANDBOX_ENABLED_KEY]: true, [SANDBOX_AUTOALLOW_KEY]: render.sandbox[SANDBOX_AUTOALLOW_KEY] };
1325
+ if (mergedCredentials === undefined) delete mergedSandbox[CREDENTIALS_KEY];
1326
+ else mergedSandbox[CREDENTIALS_KEY] = mergedCredentials;
1151
1327
  return { ...base, [SANDBOX_KEY]: mergedSandbox, permissions: mergedPermissions };
1152
1328
  };
1153
1329
 
@@ -1256,6 +1432,16 @@ const collectLocalMasks = (localData, render) => {
1256
1432
  masks.push({ key: `${SANDBOX_KEY}.${k}`, local: localSandbox[k], rendered: render.sandbox[k] });
1257
1433
  }
1258
1434
  }
1435
+ // credentials is render-owned too, and local > project applies to the WHOLE key: a local block
1436
+ // that drops or weakens a rendered entry silently removes a protection the preview just reported.
1437
+ // Compared on the owned slice only, so a local file that merely ADDS its own entries is not a mask.
1438
+ if (hasOwn(localSandbox, CREDENTIALS_KEY)) {
1439
+ const localOwned = ownedEnvVarsIn(localSandbox[CREDENTIALS_KEY]);
1440
+ const renderedOwned = ownedEnvVarsIn(render.sandbox[CREDENTIALS_KEY]);
1441
+ if (JSON.stringify(localOwned) !== JSON.stringify(renderedOwned)) {
1442
+ masks.push({ key: `${SANDBOX_KEY}.${CREDENTIALS_KEY}`, local: localOwned, rendered: renderedOwned });
1443
+ }
1444
+ }
1259
1445
  return masks;
1260
1446
  };
1261
1447
 
@@ -1310,7 +1496,7 @@ export const writeAutonomyProfile = ({ cwd, apply = false } = {}, deps = {}) =>
1310
1496
  }
1311
1497
  const resolved = resolveAutonomy(config);
1312
1498
  const probe = probeSandboxAvailability(deps);
1313
- const render = renderAutonomySettings(resolved, probe);
1499
+ const render = renderAutonomySettings(resolved, probe, probeHarnessVersion(deps));
1314
1500
  const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
1315
1501
  // local-mask honesty: a settings.local.json value for ANY render-owned key (defaultMode + the sandbox
1316
1502
  // enable/auto-allow keys) that differs from the render's MASKS it (local > project). Reported loudly;
@@ -1369,7 +1555,7 @@ export const checkAutonomyProfile = ({ cwd } = {}, deps = {}) => {
1369
1555
  if (source === 'none' || config === null) {
1370
1556
  throw makeVelocityProfileError(VELOCITY_NO_POLICY, `no ${AUTONOMY_REL} to check against — seed a policy first (set-autonomy previews, then --write)`);
1371
1557
  }
1372
- const render = renderAutonomySettings(resolveAutonomy(config), probeSandboxAvailability(deps));
1558
+ const render = renderAutonomySettings(resolveAutonomy(config), probeSandboxAvailability(deps), probeHarnessVersion(deps));
1373
1559
  const settings = readSettingsFile(join(projectDir, SETTINGS_FILE), { ...deps, cwd: projectDir });
1374
1560
  const data = settings.present ? settings.data : {};
1375
1561
  const drift = [];
@@ -1380,6 +1566,13 @@ export const checkAutonomyProfile = ({ cwd } = {}, deps = {}) => {
1380
1566
  if (liveSandbox[SANDBOX_AUTOALLOW_KEY] !== render.sandbox[SANDBOX_AUTOALLOW_KEY]) {
1381
1567
  drift.push(`${SANDBOX_KEY}.${SANDBOX_AUTOALLOW_KEY}: expected ${render.sandbox[SANDBOX_AUTOALLOW_KEY]}, found ${JSON.stringify(liveSandbox[SANDBOX_AUTOALLOW_KEY])}`);
1382
1568
  }
1569
+ // The credentials block is render-owned in BOTH directions: a removed one is a protection that
1570
+ // silently stopped applying, and a leftover one claims a protection this render did not make.
1571
+ const liveOwnedCredentials = ownedEnvVarsIn(liveSandbox[CREDENTIALS_KEY]);
1572
+ const wantOwnedCredentials = ownedEnvVarsIn(render.sandbox[CREDENTIALS_KEY]);
1573
+ if (JSON.stringify(liveOwnedCredentials) !== JSON.stringify(wantOwnedCredentials)) {
1574
+ drift.push(`${SANDBOX_KEY}.${CREDENTIALS_KEY}.envVars (render-owned entries): expected ${JSON.stringify(wantOwnedCredentials)}, found ${JSON.stringify(liveOwnedCredentials)}`);
1575
+ }
1383
1576
  const perms = getPermissions(data);
1384
1577
  if (perms[DEFAULT_MODE_KEY] !== render.defaultMode) {
1385
1578
  drift.push(`permissions.${DEFAULT_MODE_KEY}: expected ${render.defaultMode}, found ${JSON.stringify(perms[DEFAULT_MODE_KEY])}`);
@@ -1399,12 +1592,28 @@ export const checkAutonomyProfile = ({ cwd } = {}, deps = {}) => {
1399
1592
  if (inExpected !== 1) drift.push(`permissions.${want}: expected exactly one ${rule} (redlines.${rl}=${want}), found ${inExpected}`);
1400
1593
  if (inOther !== 0) drift.push(`permissions.${other}: ${rule} present ${inOther}× but redlines.${rl}=${want} (belongs under ${want})`);
1401
1594
  }
1402
- return { inSync: drift.length === 0, drift, source, level: render.level, settingsPresent: settings.present };
1595
+ // The degrades ride along even when the check PASSES. "In sync" only means the file matches the
1596
+ // render — it says nothing about what that render could not protect. Dropping them here would let
1597
+ // an unknown or too-old harness report a clean IN SYNC while the credential protection is simply
1598
+ // absent: exactly the silent-success shape this whole change exists to remove.
1599
+ // A local file that masks a render-owned key defeats the rendered value silently (local > project),
1600
+ // so the check must read it too. Without this a local block weakening sandbox.credentials still
1601
+ // produced IN SYNC while the report claimed the tokens were denied — a false all-clear about a
1602
+ // security control, which is the exact defect class this change exists to close.
1603
+ const localSettings = readSettingsFile(join(projectDir, SETTINGS_LOCAL_FILE), { ...deps, cwd: projectDir });
1604
+ const localMasks = collectLocalMasks(localSettings.present ? localSettings.data : undefined, render);
1605
+ for (const m of localMasks) {
1606
+ drift.push(`${SETTINGS_LOCAL_FILE} masks ${m.key}: local ${JSON.stringify(m.local)} overrides the rendered ${JSON.stringify(m.rendered)} (local > project)`);
1607
+ }
1608
+ return { inSync: drift.length === 0, drift, degrades: render.degrades, localMasks, source, level: render.level, settingsPresent: settings.present };
1403
1609
  };
1404
1610
 
1405
1611
  export const formatAutonomyCheck = (c) =>
1406
1612
  c.inSync
1407
- ? `autonomy --check: IN SYNC — ${SETTINGS_FILE} matches the ${c.source} render (level ${c.level}).`
1613
+ ? [
1614
+ `autonomy --check: IN SYNC — ${SETTINGS_FILE} matches the ${c.source} render (level ${c.level}).`,
1615
+ ...(c.degrades ?? []).map((d) => ` ⚠ DEGRADE: ${d}`),
1616
+ ].join('\n')
1408
1617
  : [
1409
1618
  `autonomy --check: DRIFT — ${SETTINGS_FILE} diverges from the ${c.source} render (level ${c.level}):`,
1410
1619
  ...c.drift.map((d) => ` ✗ ${d}`),