@sabaiway/agent-workflow-kit 3.2.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 +38 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/velocity.md +6 -2
- package/tools/recipes.mjs +5 -2
- package/tools/release-scan.mjs +144 -13
- package/tools/run-gates.mjs +43 -2
- package/tools/velocity-profile.mjs +229 -20
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 3.3.0 — version-pin honesty: the profile stops claiming a limit it never observed (AD-062)
|
|
8
|
+
|
|
9
|
+
`velocity --autonomy` told every user their credentials could not be protected by the sandbox —
|
|
10
|
+
naming a harness version frozen in the source, 30 patch releases stale, while a comment three lines
|
|
11
|
+
above recorded when the capability had actually arrived. A user who declared
|
|
12
|
+
`redlines.credentials=deny` got no `sandbox.credentials` key and a confident explanation that the
|
|
13
|
+
platform could not enforce it. Not a silent failure: a false claim about a security control.
|
|
14
|
+
|
|
15
|
+
Nobody can guarantee a vendor's version format, install layout or settings schema stays put, so the
|
|
16
|
+
fix is not "read the right version" — it is **never state what was not observed**. A pin goes stale
|
|
17
|
+
silently; a probe goes stale loudly, and that direction is the whole point.
|
|
18
|
+
|
|
19
|
+
- **`probeHarnessVersion`** reads the INSTALLED harness at render time — read-only PATH resolution,
|
|
20
|
+
never a spawn. Two layouts matched exactly (`claude/versions/<version>` and the
|
|
21
|
+
`@anthropic-ai/claude-code` package.json); a third-party wrapper whose name merely contains
|
|
22
|
+
"claude", a decorated version segment, and a prerelease all resolve to a STATED unknown. A
|
|
23
|
+
programming defect inside the walk is rethrown rather than reported as an unknown layout, and an
|
|
24
|
+
unreadable package.json surfaces instead of folding into "not found".
|
|
25
|
+
- **A supported build now gets the protection.** `sandbox.credentials` is rendered, MERGED into
|
|
26
|
+
settings (hand-declared `files` and foreign entries survive), drift-checked and local-mask-checked
|
|
27
|
+
— the render owns exactly the entries for the env vars it protects, nothing more.
|
|
28
|
+
- **Every honest limit is a loud degrade, not a note.** Coverage is stated PARTIAL (env vars only;
|
|
29
|
+
file credentials are NOT rendered, because that entry shape was never verified against an
|
|
30
|
+
installed build). `credentials=ask` degrades rather than being quietly upgraded to deny. An older
|
|
31
|
+
build, or one whose version cannot be RESOLVED from a recognised layout, degrades naming what was
|
|
32
|
+
observed or stating that nothing was — and a PASSING `--check` prints the degrades too, because
|
|
33
|
+
"in sync" only means the file matches the render. An UNREADABLE install (a permission error) or a
|
|
34
|
+
defect inside the probe is a different outcome on purpose: it throws loudly rather than degrading,
|
|
35
|
+
because "cannot confirm" must never read as "confirmed absent".
|
|
36
|
+
- **New `version-pin` rung in `release-scan`.** A harness version literal under `tools/` fails the
|
|
37
|
+
scan unless a runtime probe sits beside it in the same JS file. The harness series is discovered
|
|
38
|
+
from the scanned file, never hardcoded — a scanner carrying its own pin would be an instance of the
|
|
39
|
+
class it catches, and a self-refusal test enforces that. Prose earns nothing: a `#` comment in a
|
|
40
|
+
shell script or a line of Markdown cannot hold a probe, so those files stay scanned.
|
|
41
|
+
**Stated residual, argued in the source:** the rung proves a probe is PRESENT, not that its result
|
|
42
|
+
is compared with the literal. Proving the latter is JS lexing; an approximation of it was tried and
|
|
43
|
+
withdrawn, because each increment of precision opened a new hole without buying the guarantee.
|
|
44
|
+
|
|
7
45
|
## 3.2.0 — plain language + posture as code: the friction cluster shipped as mechanism (AD-061)
|
|
8
46
|
|
|
9
47
|
Carrier release for a friction cluster (kit 3.2.0 bundles the bridges + shared contract + the
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '3.
|
|
6
|
+
version: '3.3.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -55,7 +55,11 @@ Honesty notes: tier entries get **NO PreToolUse-hook residual coverage** — the
|
|
|
55
55
|
|
|
56
56
|
1. **Preview by default** (writes nothing); **`--apply`** merges the render-owned blocks into `.claude/settings.json` (merge-don't-clobber — foreign top-level keys, existing `permissions.allow`, and foreign ask/deny entries preserved; a policy flip MOVES a red-line rule between ask/deny rather than duplicating it). It **refuses an absent policy** loudly (seed one first with `set-autonomy`). It **never writes `settings.local.json`**; a `settings.local.json` `defaultMode` that would mask the render is reported (local > project).
|
|
57
57
|
2. **`--autonomy --check`** is a **read-only drift gate**: it recomputes the render and compares against the live render-owned blocks — exit `0` in sync, exit `1` on drift (naming the exact key). A hand-edit OUTSIDE those blocks never flags.
|
|
58
|
-
3. **Version-
|
|
58
|
+
3. **Version-PROBED, never version-pinned.** The render reads the INSTALLED harness version at run time (`probeHarnessVersion` — a read-only PATH resolution, never a spawn) and names only what it observed. Sandbox keys are `sandbox.enabled` + `sandbox.autoAllowBashIfSandboxed`; `sandbox` level ⇒ auto-allow + `defaultMode: acceptEdits`, `prompt` level ⇒ auto-allow OFF + `defaultMode: default` (the sandbox stays enabled as a confine floor either way). Red-lines use the argument-matching `:*` wildcard (`Bash(git commit:*)` / `Bash(git push:*)` / `Bash(npm publish:*)`). Three branches, all loud:
|
|
59
|
+
- **Observed and supported** — `sandbox.credentials` IS rendered (and merged into settings.json, and drift-checked). Coverage is stated PARTIAL: env vars only. File credentials (`~/.ssh`) are **not** rendered, because that entry shape was never verified against an installed build — declare `sandbox.credentials.files` yourself if you need it. A `credentials=ask` policy degrades loudly too: the schema offers deny/mask and no ask mode, so `ask` is never quietly upgraded.
|
|
60
|
+
- **Observed but older than the capability** — no key rendered, degrade names **the observed version**.
|
|
61
|
+
- **Version unresolvable** — no key rendered, degrade STATES the unknown and its cause. Never a guess in either direction.
|
|
62
|
+
A **network hard-block** and a **prompt-on-outside-write** are not expressed by this render at all (stated as such, without claiming a platform limit it did not observe). A missing Linux dependency (`socat`/`bwrap`) or an unsupported platform degrades the WHOLE sandbox to unsandboxed — the render still lands the red-lines + `defaultMode` and caveats that ad-hoc scripts will still prompt.
|
|
59
63
|
4. **`--autonomy` cannot combine** with `--accept-edits`, `--kit-tools`, or `--bridge-tier` (allowlist-mode flags) — a loud usage error.
|
|
60
64
|
|
|
61
|
-
**Autonomy invariants:** policy-only (never seeds the allowlist, leaves `permissions.allow` untouched) · writes only `.claude/settings.json` (never `settings.local.json`) · refuses an absent policy · merge-don't-clobber · degrades loudly
|
|
65
|
+
**Autonomy invariants:** policy-only (never seeds the allowlist, leaves `permissions.allow` untouched) · writes only `.claude/settings.json` (never `settings.local.json`) · refuses an absent policy · merge-don't-clobber · **names only the version it observed, and states the unknown when it observed none** · degrades loudly wherever the render cannot express a red-line · previews by default · never commits.
|
package/tools/recipes.mjs
CHANGED
|
@@ -394,7 +394,10 @@ const projectTopOf = (cwd) => {
|
|
|
394
394
|
}
|
|
395
395
|
};
|
|
396
396
|
|
|
397
|
-
|
|
397
|
+
// `deps` is threaded to the render check because that check now PROBES the installed harness: without
|
|
398
|
+
// a seam the render state would vary with the machine composing the facts, and a caller could not
|
|
399
|
+
// pin it. Production passes nothing and probes for real.
|
|
400
|
+
export const composeAutonomyFacts = async (cwd, deps = {}) => {
|
|
398
401
|
try {
|
|
399
402
|
const root = projectTopOf(cwd);
|
|
400
403
|
const { loadAutonomy, resolveAutonomy, isSparseSeedConfig } = await import('./autonomy-config.mjs');
|
|
@@ -411,7 +414,7 @@ export const composeAutonomyFacts = async (cwd) => {
|
|
|
411
414
|
let renderState;
|
|
412
415
|
try {
|
|
413
416
|
const { checkAutonomyProfile } = await import('./velocity-profile.mjs');
|
|
414
|
-
renderState = checkAutonomyProfile({ cwd: root }).inSync
|
|
417
|
+
renderState = checkAutonomyProfile({ cwd: root }, deps).inSync
|
|
415
418
|
? 'in sync'
|
|
416
419
|
: 'DRIFT — re-run the velocity --autonomy render';
|
|
417
420
|
} catch (err) {
|
package/tools/release-scan.mjs
CHANGED
|
@@ -63,6 +63,81 @@ export const scanText = (text, { allowlist = ALLOWLIST } = {}) => {
|
|
|
63
63
|
return findings;
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
+
// ── the version-pin rung ─────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// A hardcoded harness version under tools/ is a documentation claim frozen in code: it goes stale by
|
|
68
|
+
// default and nothing reports it. This rung refuses a BARE pin and demands a runtime probe beside
|
|
69
|
+
// it — the probe is what converts silent staleness into a loud degrade.
|
|
70
|
+
//
|
|
71
|
+
// A line is a claim when it names the harness AND carries a version literal. The rung reports
|
|
72
|
+
// exactly those lines — it deliberately does NOT spread the claimed series across the rest of the
|
|
73
|
+
// file, which was measured to flag an unrelated dependency constant that merely shared a major.minor
|
|
74
|
+
// with the claim two lines above it. Nothing is lost by the narrower rule: the exemption is
|
|
75
|
+
// FILE-scoped, so a single refusal already forces the probe that makes every literal in the file
|
|
76
|
+
// honest. (This comment names the harness without a literal on the same line ON PURPOSE — the rung
|
|
77
|
+
// scans its own source, and a worked example here would make the scanner refuse itself.)
|
|
78
|
+
//
|
|
79
|
+
// STATED RESIDUAL, not an oversight: a claim split across lines — the harness named on one line, the
|
|
80
|
+
// literal on another — is NOT detected. Closing it needs whole-file arming, which trades this narrow
|
|
81
|
+
// miss for a broad false-positive class. Deliberate line splitting is left to human review; this rung
|
|
82
|
+
// is a guard against staleness that accumulates by default, not against an author evading it.
|
|
83
|
+
const HARNESS_CLAIM_RE = /\bclaude\b/i;
|
|
84
|
+
const SEMVER_LITERAL_RE = /\b\d+\.\d+\.\d+\b/g;
|
|
85
|
+
const TOOLS_SEGMENT = 'tools';
|
|
86
|
+
const PATH_SEP_RE = /[/\\]/;
|
|
87
|
+
const VERSION_PIN_REMEDY =
|
|
88
|
+
'pair it with a runtime probe (probeHarnessVersion) that compares the pin to the installed build '
|
|
89
|
+
+ 'and degrades loudly, or move the literal into a test fixture or changelog entry, where it '
|
|
90
|
+
+ 'records history instead of asserting a live platform fact';
|
|
91
|
+
|
|
92
|
+
// Three exemptions, each because the literal is NOT a live claim there. A test fixture and a
|
|
93
|
+
// changelog entry record what was true at a moment; a file that CALLS the probe already reports its
|
|
94
|
+
// own staleness, which is the entire behaviour the rung exists to demand.
|
|
95
|
+
//
|
|
96
|
+
// The probe exemption is a REFERENCE test, deliberately: it asks exactly what the decision asks —
|
|
97
|
+
// that a runtime probe sits beside the literal — and claims nothing more.
|
|
98
|
+
//
|
|
99
|
+
// It was briefly a CALL test. Separating a call from a declaration, a comment, a string and a regex
|
|
100
|
+
// literal is JS lexing, and hand-rolling that opened a new hole in three consecutive review rounds:
|
|
101
|
+
// a legitimate member call wrongly refused, then a per-character recursion that would crash this
|
|
102
|
+
// scanner inside the commit gate, then a regex literal read as a call. The repo does own a real
|
|
103
|
+
// lexical code mask — in root tooling a shipped kit tool cannot import — and extracting it is queued
|
|
104
|
+
// as its own work rather than approximated again here.
|
|
105
|
+
//
|
|
106
|
+
// STATED RESIDUAL, and why the precision was not worth its risk: even a perfect call detector proves
|
|
107
|
+
// only that the probe is REACHABLE in this file, never that its result is compared against the
|
|
108
|
+
// literal. That stronger guarantee was never on offer, so the rung stops where the decision stops
|
|
109
|
+
// and says so. The exemption stays scoped to files that could call anything at all — a `#` comment
|
|
110
|
+
// in a shell script and a line of Markdown prose earn nothing.
|
|
111
|
+
const PROBE_CALLABLE_EXT = new Set(['.mjs', '.js']);
|
|
112
|
+
const PROBE_SYMBOL = 'probeHarnessVersion';
|
|
113
|
+
const TEST_FILE_RE = /\.test\.mjs$/;
|
|
114
|
+
const CHANGELOG_FILE_RE = /^CHANGELOG\.md$/i;
|
|
115
|
+
|
|
116
|
+
const semverLiteralsIn = (line) => line.match(SEMVER_LITERAL_RE) ?? [];
|
|
117
|
+
const isUnderTools = (path) => String(path).split(PATH_SEP_RE).includes(TOOLS_SEGMENT);
|
|
118
|
+
const isRecordNotClaim = (path) => {
|
|
119
|
+
const name = basename(String(path));
|
|
120
|
+
return TEST_FILE_RE.test(name) || CHANGELOG_FILE_RE.test(name);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// PURE: classify one file's text. Path-aware — the same literal is a claim under tools/ and history
|
|
124
|
+
// in a changelog, so the verdict cannot be made from the bytes alone.
|
|
125
|
+
export const scanVersionPins = (text, path) => {
|
|
126
|
+
if (!isUnderTools(path) || isRecordNotClaim(path)) return [];
|
|
127
|
+
const probeCallable = PROBE_CALLABLE_EXT.has(extname(String(path)));
|
|
128
|
+
if (probeCallable && text.includes(PROBE_SYMBOL)) return [];
|
|
129
|
+
return text.split('\n').flatMap((line, idx) => {
|
|
130
|
+
const pinned = HARNESS_CLAIM_RE.test(line) ? semverLiteralsIn(line) : [];
|
|
131
|
+
return pinned.length === 0
|
|
132
|
+
? []
|
|
133
|
+
: [{
|
|
134
|
+
line: idx + 1,
|
|
135
|
+
kind: 'version-pin',
|
|
136
|
+
detail: `harness version pin ${pinned.join(', ')} with no runtime probe in this file — a version literal frozen in code goes stale silently; ${VERSION_PIN_REMEDY}`,
|
|
137
|
+
}];
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
66
141
|
const TEXT_EXT = new Set(['.md', '.mjs', '.js', '.json', '.sh', '.yml', '.yaml', '.txt', '']);
|
|
67
142
|
const EXCLUDE_DIR_NAMES = new Set(['node_modules', '.git', 'plans', 'scan-fixtures']);
|
|
68
143
|
// Only the TEST is self-excluded — it necessarily contains attribution fixtures. The scanner source
|
|
@@ -81,34 +156,90 @@ const walk = (path, acc = []) => {
|
|
|
81
156
|
return acc;
|
|
82
157
|
};
|
|
83
158
|
|
|
84
|
-
|
|
159
|
+
// Per-target outcomes. A caller-NAMED target that contributes no file must never fold into the
|
|
160
|
+
// clean verdict — an empty file list and a clean one print identically, so a mis-aimed scan would
|
|
161
|
+
// read as proof of cleanliness. `unmatchedGlob` is the one benign zero: the gate declaration lists
|
|
162
|
+
// shell globs, and bash passes an unmatched glob through literally, so a path that still carries a
|
|
163
|
+
// glob metacharacter is an optional target the shell found nothing for, not a mis-aim.
|
|
164
|
+
export const TARGET = Object.freeze({
|
|
165
|
+
scanned: 'scanned',
|
|
166
|
+
missing: 'missing',
|
|
167
|
+
unmatchedGlob: 'unmatched-glob',
|
|
168
|
+
excluded: 'excluded',
|
|
169
|
+
empty: 'empty',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const GLOB_META_RE = /[*?[\]]/;
|
|
173
|
+
|
|
174
|
+
// PURE-ish (one fs read): classify ONE caller-named target and collect the files it contributes.
|
|
175
|
+
// Every zero-contribution status names its own cause, so the refusal can state the remedy rather
|
|
176
|
+
// than a bare count (a count without a location is the defect class this family bans).
|
|
177
|
+
export const classifyTarget = (path) => {
|
|
178
|
+
if (!existsSync(path)) {
|
|
179
|
+
return GLOB_META_RE.test(path)
|
|
180
|
+
? { path, status: TARGET.unmatchedGlob, files: [] }
|
|
181
|
+
: { path, status: TARGET.missing, files: [] };
|
|
182
|
+
}
|
|
183
|
+
const st = statSync(path);
|
|
184
|
+
const name = basename(path);
|
|
185
|
+
if (st.isDirectory() && EXCLUDE_DIR_NAMES.has(name)) return { path, status: TARGET.excluded, files: [] };
|
|
186
|
+
if (st.isFile() && (EXCLUDE_FILE_NAMES.has(name) || !TEXT_EXT.has(extname(path)) || path.endsWith('.tgz'))) {
|
|
187
|
+
return { path, status: TARGET.excluded, files: [] };
|
|
188
|
+
}
|
|
189
|
+
const files = walk(path);
|
|
190
|
+
return { path, status: files.length > 0 ? TARGET.scanned : TARGET.empty, files };
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Why each zero-contribution status refuses, and the one legal remedy for it.
|
|
194
|
+
const TARGET_REMEDY = Object.freeze({
|
|
195
|
+
[TARGET.missing]: 'the path does not exist — fix the target, or drop it from the target list',
|
|
196
|
+
[TARGET.excluded]: `the target itself is excluded (directory names: ${[...EXCLUDE_DIR_NAMES].join(', ')}; non-text or self-excluded files) — name the FILES to scan instead of the directory`,
|
|
197
|
+
[TARGET.empty]: 'the target exists but holds no scannable text file — fix the target, or drop it from the target list',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const findingsIn = (targets, opts) => {
|
|
85
201
|
const report = [];
|
|
86
|
-
for (const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
for (const
|
|
202
|
+
for (const target of targets) {
|
|
203
|
+
for (const file of target.files) {
|
|
204
|
+
const text = readFileSync(file, 'utf8');
|
|
205
|
+
for (const finding of scanText(text, opts)) report.push({ file, ...finding });
|
|
206
|
+
for (const finding of scanVersionPins(text, file)) report.push({ file, ...finding });
|
|
91
207
|
}
|
|
92
208
|
}
|
|
93
209
|
return report;
|
|
94
210
|
};
|
|
95
211
|
|
|
212
|
+
export const scanPaths = (paths, opts = {}) => findingsIn(paths.map((p) => classifyTarget(resolve(p))), opts);
|
|
213
|
+
|
|
96
214
|
export const main = (argv) => {
|
|
97
215
|
const reportOnly = argv.includes('--report');
|
|
98
216
|
const paths = argv.filter((a) => a !== '--report');
|
|
99
217
|
if (paths.length === 0) {
|
|
100
218
|
console.error('usage: release-scan.mjs [--report] <path>...');
|
|
101
|
-
|
|
219
|
+
return 2;
|
|
220
|
+
}
|
|
221
|
+
const targets = paths.map((p) => classifyTarget(resolve(p)));
|
|
222
|
+
for (const target of targets) {
|
|
223
|
+
if (target.status === TARGET.unmatchedGlob) {
|
|
224
|
+
console.log(`[release-scan] skipped ${target.path} — no file matched this glob (an optional target, stated not silent).`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const misaimed = targets.filter((target) => TARGET_REMEDY[target.status] !== undefined);
|
|
228
|
+
for (const target of misaimed) {
|
|
229
|
+
console.log(`[release-scan] target ${target.path} contributed NO file (${target.status}) — ${TARGET_REMEDY[target.status]}`);
|
|
102
230
|
}
|
|
103
|
-
const report =
|
|
231
|
+
const report = findingsIn(targets);
|
|
104
232
|
for (const r of report) console.log(`[${r.kind}] ${r.file}:${r.line} — ${r.detail}`);
|
|
105
|
-
if (
|
|
233
|
+
if (misaimed.length > 0) {
|
|
234
|
+
console.log(`\n[release-scan] ${misaimed.length} mis-aimed target(s) — this run proves nothing about them, so the clean verdict is refused.`);
|
|
235
|
+
}
|
|
236
|
+
if (report.length > 0) console.log(`\n[release-scan] ${report.length} finding(s).`);
|
|
237
|
+
if (report.length === 0 && misaimed.length === 0) {
|
|
106
238
|
console.log('[release-scan] clean — no AI attribution or reviewer-round identity found.');
|
|
107
|
-
return;
|
|
239
|
+
return 0;
|
|
108
240
|
}
|
|
109
|
-
|
|
110
|
-
if (!reportOnly) process.exit(1);
|
|
241
|
+
return reportOnly ? 0 : 1;
|
|
111
242
|
};
|
|
112
243
|
|
|
113
244
|
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
114
|
-
if (isDirectRun) main(process.argv.slice(2));
|
|
245
|
+
if (isDirectRun) process.exitCode = main(process.argv.slice(2));
|
package/tools/run-gates.mjs
CHANGED
|
@@ -77,6 +77,12 @@ const USAGE = [
|
|
|
77
77
|
'',
|
|
78
78
|
`Runs the gates declared in <cwd>/${GATES_REL} (one bash command line each, project root as cwd).`,
|
|
79
79
|
'Prints a per-gate PASS/FAIL table + one machine-readable summary line; exit 0 iff all green.',
|
|
80
|
+
'',
|
|
81
|
+
'Producer env: AW_GIT_DIR (inside a git tree) and AW_LCOV_FILE (--final only) are computed and',
|
|
82
|
+
'exported to every gate child, and STRIPPED from the inherited environment first — a host-set',
|
|
83
|
+
'value never stands in for a computed one. A selected gate referencing a producer variable this',
|
|
84
|
+
'run will not set is refused BEFORE anything spawns (exit 1), naming the gate, the variable, and',
|
|
85
|
+
'the remedy — never left to expand to empty and fail far from its cause.',
|
|
80
86
|
'Sandbox-safe: the runner itself needs no network and writes only repo-local state — the D4 sandbox',
|
|
81
87
|
'lane; each DECLARED gate command is the project\'s own, so ITS sandbox-safety is command-shape',
|
|
82
88
|
'dependent (first try the sandbox-safe shape — cache under $TMPDIR, offline/notifier off).',
|
|
@@ -184,12 +190,37 @@ export const selectGates = (gates, onlyIds) => {
|
|
|
184
190
|
// NODE_TEST_CONTEXT is stripped: a `node --test` gate spawned while run-gates is itself running
|
|
185
191
|
// under a parent test context would otherwise inherit it, hit Node's recursive-run guard, silently
|
|
186
192
|
// skip every file, and exit 0 — a vacuous false green.
|
|
193
|
+
// The variables this runner PRODUCES for gate children. They are stripped from the inherited
|
|
194
|
+
// environment before every spawn: composing the child env as {...process.env, ...injected} would
|
|
195
|
+
// let a stale or hostile HOST value stand in whenever this run injects nothing, so a gate would
|
|
196
|
+
// silently attest against the wrong git dir or lcov instead of the computed one.
|
|
197
|
+
export const RESERVED_PRODUCER_ENV = Object.freeze(['AW_GIT_DIR', 'AW_LCOV_FILE']);
|
|
198
|
+
|
|
187
199
|
export const spawnGateViaBash = (cmd, cwd, extraEnv = {}) => {
|
|
188
|
-
const env = { ...process.env
|
|
200
|
+
const env = { ...process.env };
|
|
189
201
|
delete env.NODE_TEST_CONTEXT;
|
|
190
|
-
|
|
202
|
+
for (const name of RESERVED_PRODUCER_ENV) delete env[name];
|
|
203
|
+
return spawnSync('bash', ['-c', cmd], { cwd, env: { ...env, ...extraEnv }, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
|
|
191
204
|
};
|
|
192
205
|
|
|
206
|
+
// A `$VAR` / `${VAR}` reference to a producer variable. A `${VAR:-default}` form is deliberately NOT
|
|
207
|
+
// matched — a cmd carrying its own fallback does not depend on the injection.
|
|
208
|
+
const referencesProducer = (cmd, name) => new RegExp(`\\$\\{${name}\\}|\\$${name}(?![A-Za-z0-9_])`).test(cmd);
|
|
209
|
+
|
|
210
|
+
const PRODUCER_RECOVERY = Object.freeze({
|
|
211
|
+
AW_GIT_DIR: 'run from inside a git work tree — the runner resolves the git dir there and exports it to every gate.',
|
|
212
|
+
AW_LCOV_FILE: 'AW_LCOV_FILE is produced only by --final — run with --final, or drop the reference from the gate cmd.',
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Which SELECTED gates reference a producer variable this run will not set. Such a reference
|
|
216
|
+
// expands to empty and fails the gate somewhere far from its cause, so it is refused before
|
|
217
|
+
// anything spawns, naming the gate, the variable, and the one remedy.
|
|
218
|
+
export const findUnmetProducerRefs = (gates, injected) =>
|
|
219
|
+
gates.flatMap((gate) =>
|
|
220
|
+
RESERVED_PRODUCER_ENV
|
|
221
|
+
.filter((name) => !injected.includes(name) && referencesProducer(gate.cmd, name))
|
|
222
|
+
.map((name) => ({ id: gate.id, name })));
|
|
223
|
+
|
|
193
224
|
// The command the bash preflight runs before ANY gate: proves bash itself spawns on this host,
|
|
194
225
|
// so "no bash" is one loud exit-6 error up front — never a per-gate spawn-failure cascade.
|
|
195
226
|
export const BASH_PROBE_CMD = 'true';
|
|
@@ -378,6 +409,16 @@ export const runCli = (argv, deps = {}) => {
|
|
|
378
409
|
// gate child env — a stray host AW_LCOV_FILE can never desync the checker from the receipt.
|
|
379
410
|
gateSpawn = (cmd, cwd2) => spawn(cmd, cwd2, { AW_GIT_DIR: gitDir, AW_LCOV_FILE: join(gitDir, LCOV_BASENAME) });
|
|
380
411
|
}
|
|
412
|
+
const injectedEnv = [...(gitDir === null ? [] : ['AW_GIT_DIR']), ...(opts.final ? ['AW_LCOV_FILE'] : [])];
|
|
413
|
+
const unmet = findUnmetProducerRefs(selected, injectedEnv);
|
|
414
|
+
if (unmet.length > 0) {
|
|
415
|
+
for (const { id, name } of unmet) {
|
|
416
|
+
logError(`[run-gates] gate "${id}" references $${name}, which this run will not set — it would expand to empty and fail far from its cause.`);
|
|
417
|
+
logError(` Recovery: ${PRODUCER_RECOVERY[name]}`);
|
|
418
|
+
}
|
|
419
|
+
log(composeSummaryLine({ status: 'fail' }));
|
|
420
|
+
return EXIT.fail;
|
|
421
|
+
}
|
|
381
422
|
const probe = spawn(BASH_PROBE_CMD, projectDir);
|
|
382
423
|
if (probe.error && probe.error.code === 'ENOENT') {
|
|
383
424
|
logError(
|
|
@@ -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}`),
|