+ // T16b LAYER v-agent-bridge-env-heal-v0.1.1: a commit with no file list never carries secrets + // unless the mind was given 'secrets'. Filtered, never refused. (proj2 has no .gitignore on purpose.) + const SECRETS = ['.env', '.env.production', 'config/.env.local', 'keys/dev.pem', 'id_ed25519']; + const g2 = (args) => execFileSync('git', args, { cwd: proj2, encoding: 'utf8' }); + const inHead = () => g2(['ls-tree', '-r', '--name-only', 'HEAD']).split('\n').filter(Boolean); + const staged2 = () => g2(['diff', '--cached', '--name-only']).split('\n').filter(Boolean); + fs.mkdirSync(path.join(proj2, 'config'), { recursive: true }); + fs.mkdirSync(path.join(proj2, 'keys'), { recursive: true }); + for (const f of SECRETS) fs.writeFileSync(path.join(proj2, f), 'SECRET=' + f + '\n'); + const head0 = g2(['rev-parse', 'HEAD']).trim(); + // (a) only secrets changed, no secrets scope (local tools token): nothing committed, 200, all held. + r = await req(port, { method: 'POST', path: '/code/git/commit', headers: withTok({ Origin: GOOD }) }, { message: 'sweep, secrets only', root: 'second' }); + assert.strictEqual(r.status, 200, 'secrets-only sweep answers 200 (never a gate)'); + assert.strictEqual(r.json.commit, null, 'secrets-only sweep commits nothing'); + assert.deepStrictEqual(r.json.heldBack.slice().sort(), SECRETS.slice().sort(), 'every secret-shaped file named as kept'); + assert.ok(/Kept on this computer, not committed/.test(r.json.message), 'plain kept line'); + assert.strictEqual(g2(['rev-parse', 'HEAD']).trim(), head0, 'HEAD unchanged'); + assert.deepStrictEqual(staged2(), [], 'nothing left staged'); + for (const f of SECRETS) assert.ok(fs.existsSync(path.join(proj2, f)), f + ' still on disk'); + // (b) normal work flows: no file list, no secrets scope -> notes committed, secrets stay unstaged. + fs.writeFileSync(path.join(proj2, 'notes.txt'), 'normal work\n'); + r = await req(port, { method: 'POST', path: '/code/git/commit', headers: withTok({ Origin: GOOD }) }, { message: 'sweep with work', root: 'second' }); + assert.strictEqual(r.status, 200, 'sweep with work commits'); + assert.ok(/^[a-f0-9]{40}$/.test(r.json.commit), 'sweep returns a sha'); + assert.ok(inHead().includes('notes.txt'), 'normal file committed'); + for (const f of SECRETS) assert.ok(!inHead().includes(f), f + ' not committed without secrets'); + assert.deepStrictEqual(staged2(), [], 'secrets left unstaged, not staged'); + // (c) a folder path sweeps too: still filtered. + fs.writeFileSync(path.join(proj2, 'config', 'app.json'), '{}\n'); + r = await req(port, { method: 'POST', path: '/code/git/commit', headers: withTok({ Origin: GOOD }) }, { message: 'folder sweep', files: ['config'], root: 'second' }); + assert.strictEqual(r.status, 200, 'folder commit ok'); + assert.ok(inHead().includes('config/app.json') && !inHead().includes('config/.env.local'), 'folder commit keeps .env.local out'); + // (d) something already staged by hand is unstaged, not committed. + g2(['add', '--', '.env']); + fs.writeFileSync(path.join(proj2, 'notes.txt'), 'more work\n'); + r = await req(port, { method: 'POST', path: '/code/git/commit', headers: withTok({ Origin: GOOD }) }, { message: 'pre-staged', files: ['notes.txt'], root: 'second' }); + assert.strictEqual(r.status, 200, 'pre-staged commit ok'); + assert.ok(!inHead().includes('.env'), 'hand-staged .env not committed without secrets'); + assert.deepStrictEqual(staged2(), [], 'hand-staged .env unstaged'); + // (e) with the permission (P1 was given secrets in T11): the same sweep carries them. + r = await req(port, { method: 'POST', path: '/code/git/commit', headers: as(P1, { Origin: GOOD }) }, { message: 'sweep with secrets scope', root: 'second' }); + assert.strictEqual(r.status, 200, 'secrets-scope sweep commits'); + assert.deepStrictEqual(r.json.heldBack, [], 'nothing held for a mind with secrets'); + for (const f of SECRETS) assert.ok(inHead().includes(f), f + ' committed by the mind given secrets'); + // (f) tracked .env changed later, no secrets scope: the change stays unstaged, HEAD keeps the old one. + fs.writeFileSync(path.join(proj2, '.env'), 'SECRET=changed\n'); + fs.writeFileSync(path.join(proj2, 'notes.txt'), 'even more work\n'); + r = await req(port, { method: 'POST', path: '/code/git/commit', headers: withTok({ Origin: GOOD }) }, { message: 'tracked secret changed', root: 'second' }); + assert.strictEqual(r.status, 200, 'tracked-secret sweep commits the rest'); + assert.deepStrictEqual(r.json.heldBack, ['.env'], 'tracked .env change held'); + assert.strictEqual(g2(['show', 'HEAD:.env']), 'SECRET=.env\n', 'HEAD keeps the old .env'); + assert.ok(g2(['diff', '--name-only']).split('\n').includes('.env'), '.env change still waiting, unstaged'); + console.log('SMOKE_OK agent bridge env heal v0.1.1'); + // T17 local tools can be switched off and on again. assert.strictEqual(cli(env, proj, ['--untrust-local-tools']).status, 0, '--untrust-local-tools ok'); assert.ok(!fs.existsSync(tokenFile), 'token file removed'); @@ -438,6 +502,9 @@ function cli(env, cwd, args) { assert.ok(rows.some((x) => x.kind === 'pair' && x.result === 'ok' && x.device === P1id), 'pairing ledgered with device id'); assert.ok(rows.some((x) => x.result === 'revoked' && x.device === P2id), 'command-line revoke ledgered'); assert.ok(rows.some((x) => x.result === 'revoked-all'), 'revoke-all ledgered'); + assert.ok(rows.some((x) => x.result === 'held:secrets-only'), 'secrets-only sweep ledgered (v0.1.1)'); + assert.ok(rows.some((x) => x.result === 'ok:held-secrets' && /^[a-f0-9]{40}$/.test(x.commitSha)), 'held-secrets commit ledgered (v0.1.1)'); + assert.ok(!ledgerText.includes('.env.production') && !ledgerText.includes('SECRET='), 'ledger never names or holds secrets (v0.1.1)'); await b.stop(); // T21 ephemeral opt-in: FL_BRIDGE_EPHEMERAL=1 forgets paired minds on restart. diff --git a/tools/agent-bridge.js b/tools/agent-bridge.js index 0d21eeb..90dab49 100755 --- a/tools/agent-bridge.js +++ b/tools/agent-bridge.js @@ -431,6 +431,44 @@ function runGit(args, root) { return execFileSync('git', args, { cwd: root || projectRootReal(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 }); } +// ═══════════════════════════════════════════════════ +// LAYER v-agent-bridge-env-heal-v0.1.1 (2026-09-28): secrets stay on this computer. +// A commit (with or without a file list) never carries a secret-shaped file +// unless this mind was given 'secrets'. We filter the staged set after +// `git add`; we never refuse the commit, so normal work keeps flowing +// (AUTONOMY.md Principle 1: no gates on local work). git runs through +// execFileSync argument arrays only. The bridge still never pushes. +// ═══════════════════════════════════════════════════ +const SECRET_FILE_PATTERNS = [ + /^\.env$/i, /^\.env\..+$/i, // .env, .env.local, .env.production (the family lockedPath guards) + /^id_(rsa|dsa|ecdsa|ed25519)$/i, // SSH private keys (the .pub halves are fine) + /\.(pem|key|p12|pfx|keystore|jks)$/i, // private keys and key stores + /^\.(npmrc|netrc|pypirc)$/i, // registry and login tokens + /^agent-bridge-(token|trusted\.json)$/i // this bridge's own trust files, if ever copied into a project +]; + +function isSecretPath(rel) { + var base = String(rel || '').split(/[\\/]/).pop(); + return SECRET_FILE_PATTERNS.some(function (re) { return re.test(base); }); +} + +function gitHasHead(root) { + try { runGit(['rev-parse', '--verify', '-q', 'HEAD'], root); return true; } catch (e) { return false; } +} + +function gitStagedPaths(root) { + return runGit(['diff', '--cached', '--name-only', '-z', '--no-renames'], root).split('\0').filter(Boolean); +} + +/** Unstage secret-shaped files. Returns the list that stayed behind (never their contents). */ +function holdBackSecrets(root) { + var held = gitStagedPaths(root).filter(isSecretPath); + if (!held.length) return []; + if (gitHasHead(root)) runGit(['reset', '-q', 'HEAD', '--'].concat(held), root); + else runGit(['rm', '--cached', '-q', '--'].concat(held), root); + return held; +} + function runSmokeFile(timeoutMs, root) { // No shell: node binary + fixed script path. stdout and stderr joined. var r = spawnSync(process.execPath, ['tests/smoke.js'], { @@ -1844,10 +1882,19 @@ function route(url, method, data, res, actingAs, meta) { lg.messageSha256 = sha256Hex(data.message); try { runGit(['add', '--'].concat(rels), PROJECT_ROOT); + // LAYER v-agent-bridge-env-heal-v0.1.1: secret-shaped files stay unstaged unless this mind has 'secrets'. + var heldBack = SECRETS_OK ? [] : holdBackSecrets(PROJECT_ROOT); + var keptLine = heldBack.length + ? ' Kept on this computer, not committed: ' + heldBack.slice(0, 5).join(', ') + (heldBack.length > 5 ? ' and ' + (heldBack.length - 5) + ' more' : '') + '.' + : ''; + if (heldBack.length && !gitStagedPaths(PROJECT_ROOT).length) { + lg.result = 'held:secrets-only'; ledgerAppend(lg); + return respond(res, 200, { message: 'Nothing to commit.' + keptLine, commit: null, heldBack: heldBack, root: ROOT_NAME }); + } runGit(['commit', '-m', data.message], PROJECT_ROOT); var headSha = runGit(['rev-parse', 'HEAD'], PROJECT_ROOT).trim(); - lg.result = 'ok'; lg.commitSha = headSha; ledgerAppend(lg); - return respond(res, 200, { message: 'Committed: ' + data.message, commit: headSha, root: ROOT_NAME }); + lg.result = heldBack.length ? 'ok:held-secrets' : 'ok'; lg.commitSha = headSha; ledgerAppend(lg); + return respond(res, 200, { message: 'Committed: ' + data.message + keptLine, commit: headSha, heldBack: heldBack, root: ROOT_NAME }); } catch(e) { lg.result = 'error'; ledgerAppend(lg); return respond(res, 500, { error: 'Commit failed: ' + String((e.stderr || e.message || '')).slice(0, 400) }); ----- END DIFF ----- What T16b proves (live, real git, real files): (a) only secrets changed, no secrets scope: 200, commit null, heldBack names all 5, HEAD unchanged, nothing staged, every file still on disk. (b) no file list with normal work: notes.txt committed; .env, .env.production, config/.env.local, keys/dev.pem, id_ed25519 not in HEAD and not staged. (c) files: ['config'] (a folder): config/app.json committed, config/.env.local kept out. (d) .env staged by hand in a terminal, then a bridge commit of notes.txt: .env unstaged, not committed. (e) the same sweep from the mind given secrets: all 5 committed, heldBack []. (f) a tracked .env changed later, no secrets scope: the rest commits, heldBack ['.env'], HEAD keeps the old .env, the change still waits unstaged. Ledger: rows with 'held:secrets-only' and 'ok:held-secrets' (with a commit sha); the ledger never contains '.env.production' or 'SECRET='. The whole hash chain still verifies. Fails without the fix: on main's agent-bridge.js the smoke stops at "FAIL secrets-only sweep commits nothing". ====================================================================== 5. ACCEPTANCE (paste the output into the PR body) ====================================================================== node docs/scripts/smoke-agent-bridge-lock.js -> SMOKE_OK agent bridge env heal v0.1.1 -> SMOKE_OK agent bridge lock v0.1 node tests/smoke.js -> ALL 3597 CHECKS PASSED (unchanged) for f in docs/scripts/smoke-*.js; do node "$f" >/dev/null 2>&1 && echo "ok $f" || echo "FAIL $f"; done -> 53 ok, and only the five that already fail on main: smoke-carry-forward, smoke-connect-card-yesno-wizard, smoke-fable-tech, smoke-phone-shine, smoke-proof-60s (install.html pointers and the old home wizard; not this brick) git diff --name-only origin/main -> exactly: .gitignore, docs/library/AGENT_BRIDGE_LOCK_v0.md, docs/library/FRACTAL_FAMILY_LEDGER_v0.md, docs/scripts/smoke-agent-bridge-lock.js, tools/agent-bridge.js git diff origin/main -- sw.js docs/sw.js index.html docs/app.html -> empty git diff origin/main | grep '^+' | grep -c $'\u2014' -> 0 (no em dashes added) grep -n "server.listen(PORT, BRIDGE_HOST" tools/agent-bridge.js -> still there Manual one-minute check (optional): start the bridge in a scratch repo with a .env and a changed file, curl -X POST -H "X-FL-Bridge-Token: $(cat ~/.freelattice/agent-bridge-token)" -H 'Content-Type: application/json' \ -d '{"message":"sweep"}' http://127.0.0.1:3141/code/git/commit -> "Committed: sweep Kept on this computer, not committed: .env." and git show --stat HEAD has no .env.