@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.
- package/CHANGELOG.md +71 -0
- package/SKILL.md +3 -3
- package/bridges/antigravity-cli-bridge/SKILL.md +6 -4
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +84 -7
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +113 -0
- package/bridges/antigravity-cli-bridge/bin/agy.sh +55 -6
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
- package/bridges/antigravity-cli-bridge/capability.json +4 -2
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +5 -0
- package/bridges/codex-cli-bridge/SKILL.md +8 -4
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +129 -11
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +211 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +76 -13
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +47 -0
- package/bridges/codex-cli-bridge/capability.json +19 -4
- package/bridges/codex-cli-bridge/references/driving-codex.md +11 -0
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +3 -2
- package/references/modes/upgrade.md +9 -4
- package/references/modes/velocity.md +8 -2
- package/references/shared/command-shapes.md +22 -0
- package/references/shared/composition-handoff.md +7 -2
- package/references/templates/agent_rules.md +10 -1
- package/tools/bridge-settings-read.mjs +25 -5
- package/tools/delegation.mjs +2 -2
- package/tools/detect-backends.mjs +10 -0
- package/tools/lens-region.mjs +113 -36
- package/tools/recipes.mjs +5 -2
- package/tools/release-scan.mjs +147 -13
- package/tools/run-gates.mjs +43 -2
- 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
|
|
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
|
|
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
|
|
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
|
|
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 —
|
|
1107
|
-
//
|
|
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(
|
|
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 —
|
|
1114
|
-
//
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
?
|
|
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}`),
|