Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,44 @@ Writes are **write-through to one primary** (it serializes them, so there are no
multi-master conflicts); concurrent edits to different entries merge cleanly (ULID ids),
and same-entry edits are last-writer-wins with the prior value kept in history. Online
writes require connectivity. **Tokens** are never stored in `config.json` — they live in
`~/.braincontext/credentials.json` (mode `0600`) or `BCTX_TOKEN_<PROJECT>` env. Auth and
per-member permissions are a planned managed-service layer; today, group members share a
project token.
`~/.braincontext/credentials.json` (mode `0600`) or `BCTX_TOKEN_<PROJECT>` env.

> A plain SQLite file on S3/R2 is **not** a sync backend (single-writer only); use it for
> backup, not multi-user sync. libSQL replicas are the supported path.

## Users & permissions

A shared project can name its members and give each one a role. Roles are enforced by
every bctx surface — the CLI, `bctx studio`, and the MCP server — and every mutation is
attributed and logged.

```bash
bctx access init # become owner; switch enforcement on
bctx access user add ana --role writer # prints a one-paste join code
bctx project join <code> # on ana's machine — stores her key, done

bctx whoami # who am I here, and what may I do
bctx access user ls # roles at a glance
bctx access user update ana --cap "-delete" # per-user exception to the role
bctx access key revoke <keyId> # takes effect at each client's next sync
bctx access log --deny-only # what was refused, and to whom
```

Roles: `owner` and `admin` (everything), `writer` (read/write/delete + files), `reader`
(read only). `--cap "+x,-y"` layers exceptions over a role. Keys are shown **once**, at
creation, and stored only as scrypt hashes.

> **This is advisory, not a security boundary.** Clients sync against the libSQL primary
> directly, so anyone holding the raw database token can bypass these rules with any
> SQLite client — and the join code contains that token. It gives you roles, attribution,
> an audit trail, revocation, and protection against mistakes; it does not contain someone
> who sets out to defeat it. Hand join codes only to people you would trust with full
> access. Locked out? `bctx access recover --db <file>` works on any store file you can
> open on disk.

Access control is **off** until you run `bctx access init`, and a project that never opts
in behaves exactly as it always has.

## Skills (for agents)

The CLI ships its own **bundled, version-matched skill docs** with progressive
Expand Down
50 changes: 45 additions & 5 deletions progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,51 @@ and remote connections, so "go online" is a config change, not a rewrite. No aut
- [x] Vitest: `test/registry.test.ts` (precedence, token isolation + 0600, default seed) and `test/online.test.ts` (FTS5 lock + faithful remote seed); **59 tests** total, all gates green.
- [x] Verified end-to-end on `dist/`: project create/use/isolation/`--project` override/status/path/rm (local). The replica/sync round-trip needs a real `libsql://` remote (manually verifiable); the seed (its core) is unit-tested.

> **Auth/permissions = North Star, not built.** Future managed control-plane: accounts,
> project membership, RBAC (owner/editor/viewer), and server-minted scoped tokens
> (`bctx login`, `bctx project share … --role editor`). Reuses `agent_source` +
> `context_history` for attribution/audit. **Phase 3:** offline-write reconciliation
> (Turso CDC / `updated_at` merge). **S3/R2** = backup only, never a sync backend.
> **Phase 3:** offline-write reconciliation (Turso CDC / `updated_at` merge).
> **S3/R2** = backup only, never a sync backend.

## Access control — users, keys, roles (shipped)

Per-member permissions on a shared project. Migration `0004_access` adds `principals`,
`principal_keys` (scrypt hashes + a public lookup prefix; the secret is never stored) and
`access_log`, plus a nullable `principal_id` on `contexts`/`context_history`/`files`.
Inert until `store_config['access.enabled']` is set, so every existing project is
unaffected — `resolveSession` short-circuits on one indexed config read.

- [x] **Core** — `src/core/access/`: `capabilities` (9 capabilities × 4 roles + per-user
overrides), `keys` (scrypt, `bctxk.<prefix>.<secret>`, all failure modes distinguished),
`principals` (CRUD + last-owner / admin-vs-owner policy), `session` (+ `AsyncLocalStorage`
for attribution), `gate` (`authorize` + `restrictForSession`), `readonly` (Kysely
write-rejecting plugin, allow-list so new node kinds fail closed), `audit`, `joincode`
(checksummed, so a mangled paste says so), `cache` (60s revocation window for long-lived
surfaces).
- [x] **CLI** — gated inside `withDb` via a command-path → capability map
(`src/core/access/commands.ts`), filled in by `dbOptsFrom`, so **no command handler
changed**. `buildProgram()` extracted to `src/program.ts` so a test can walk the tree;
unmapped paths fail closed. New: `bctx access {init,status,disable,recover,user,key,log}`,
`bctx whoami`, `bctx project join <code>`.
- [x] **MCP** — `installAccessGate` wraps `registerTool`/`registerResource` once, so all 33
tools (and any added later) are gated; denials come back as a readable `isError` message
an agent can act on.
- [x] **Studio** — `accessGuard` beside `localOnlyGuard`, rule-based route→capability mapping,
cookie sessions with local-key adoption (the admin who launched Studio is already signed
in), `/api/auth/*`, `/api/access/*`, a login gate, and a Users & access settings panel
that reveals each secret exactly once.
- [x] **Latent bug fixed** — `SEED_TABLES` in `src/core/dump.ts` listed only the `0001` tables,
so `migrate-online` silently dropped `page_properties`, `store_config` (the storage
credentials!) and `files`. Now complete, with a drift test against `sqlite_master`.
- [x] Vitest: `access`, `command-caps`, `mcp-access`, `studio-auth` (+ migration/seed
coverage) — **319 tests**, all gates green. Verified end-to-end on `dist/` across two
isolated `BCTX_HOME`s (join code → reader → denials → promotion → revocation → recovery)
and in the browser.

> **Advisory, and documented as such.** Clients sync against the libSQL primary directly, so
> the raw database token bypasses all of this and the join code carries that token. What this
> buys: roles, attribution, audit, revocation, mistake-prevention across every bctx surface.
> `access.mode` is reserved (`advisory | token | relay`) so the two hard-enforcement upgrades
> — per-user Turso tokens (real read-only), then a write-relay — are a value change, not a
> redesign. **Still out of scope:** binding Studio beyond `127.0.0.1` (no TLS, no rate limit),
> and resource-scoped permissions (per-tag / per-namespace).

## Concurrency hardening — multi-agent stress pass (shipped)

Expand Down
101 changes: 6 additions & 95 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,105 +1,16 @@
#!/usr/bin/env node
import { Command } from 'commander'
import { ZodError } from 'zod'
import { addCommand } from './commands/add'
import { configCommand } from './commands/config'
import { exportCommand } from './commands/export'
import { fileCommand } from './commands/file'
import { getCommand } from './commands/get'
import { importCommand } from './commands/import'
import { initCommand } from './commands/init'
import { listCommand } from './commands/list'
import { mcpCommand } from './commands/mcp'
import { projectCommand } from './commands/project'
import { rmCommand } from './commands/rm'
import { searchCommand } from './commands/search'
import { skillCommand } from './commands/skill'
import { skillsCommand } from './commands/skills'
import { statusCommand } from './commands/status'
import { studioCommand } from './commands/studio'
import { updateCommand } from './commands/update'
import { wikiCommand } from './commands/wiki'
import { getVersion } from './lib/pkg'

const program = new Command()

program
.name('bctx')
.description(
'braincontext — a local-first context store for AI agents.\n' +
'Preferred workflow: build a linked knowledge wiki (bctx wiki). The direct\n' +
'context commands (add/get/list/search/update/rm) are for individual entries.',
)
.version(getVersion(), '-v, --version')
.option('--db <path>', 'explicit path to the SQLite store')
.option('--project <name>', 'use a named project from the registry')
.option('--global', 'use the global store (~/.braincontext/store.db)')
.option('--local', 'use the project store (./.braincontext/store.db)')
.option('--no-sync', 'skip the online sync for this command (replica projects)')

program.addCommand(initCommand())
// Orient: where is the store, what's in it, are exports stale.
program.addCommand(statusCommand())
// Project & sync management.
program.addCommand(projectCommand())
// Per-store config (in the DB, travels with the project) + S3/R2 file storage.
program.addCommand(configCommand())
program.addCommand(fileCommand())
// Preferred workflow first.
program.addCommand(wikiCommand())
// Direct context operations (individual entries).
program.addCommand(addCommand())
program.addCommand(getCommand())
program.addCommand(listCommand())
program.addCommand(updateCommand())
program.addCommand(rmCommand())
program.addCommand(searchCommand())
// Agent-facing surfaces.
program.addCommand(skillsCommand())
program.addCommand(skillCommand())
program.addCommand(exportCommand())
program.addCommand(importCommand())
program.addCommand(mcpCommand())
// Human-facing surface: local web UI + JSON API.
program.addCommand(studioCommand())

program.addHelpText(
'after',
`
Preferred — knowledge wiki (durable, linked, compounding):
$ bctx wiki ingest ./article.md --title "TLS notes" # store a source + synthesis checklist
$ echo "See [[Gateway]]." | bctx wiki new "OAuth2" --type concept --file -
$ bctx wiki link "OAuth2" "Gateway" --type relates
$ bctx wiki search "tls" · bctx wiki lint · bctx wiki index
$ bctx skills get braincontext-wiki --full # the wiki-maintainer playbook

Individual context operations (single entries — CRUD):
$ echo "Use pnpm, never npm" | bctx add --kind rule --tags tooling --agent claude
$ bctx list --kind rule --json · bctx search "pnpm" · bctx get <id>
$ bctx update <id> --add-tag important · bctx rm <id>

Files in S3/R2 (blobs in your bucket, metadata in the store):
$ bctx config set storage.endpoint https://<account>.r2.cloudflarestorage.com
$ bctx config set storage.bucket notes && bctx file test
$ bctx file add ./diagram.png # prints wiki embed snippets
$ bctx file ls · bctx file url <id> · bctx file rm <id>

Projects & online sync (same context across sessions, devices, members):
$ bctx project create work · bctx project use work
$ bctx project migrate-online work --url libsql://… --auth-token … # go online
$ bctx project link work --url libsql://… --auth-token … # on another device

Wiki pages are hidden from plain list/search (use --include-wiki to include them).
Store precedence: --db/BCTX_DB > --global/--local > --project/BCTX_PROJECT >
current project > ./.braincontext (if present) > default project (~/.braincontext)
`,
)
import { AccessDeniedError } from './core/access/errors'
import { buildProgram } from './program'

try {
await program.parseAsync(process.argv)
await buildProgram().parseAsync(process.argv)
} catch (err) {
if (err instanceof ZodError) {
console.error(err.issues.map((i) => i.message).join('; '))
} else if (err instanceof AccessDeniedError) {
// Already a complete, actionable sentence (see access/session.ts describeFailure).
console.error(err.message)
} else {
console.error(err instanceof Error ? err.message : String(err))
}
Expand Down
23 changes: 22 additions & 1 deletion src/commands/_shared.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,40 @@
import type { Command } from 'commander'
import type { Kysely } from 'kysely'
import { resolveCommandCapability } from '../core/access/commands'
import { type Context, getContext } from '../core/contexts'
import type { DbOpts } from '../core/paths'
import type { Database } from '../core/types'

/** Pull the inherited global store flags (--db/--global/--local/--project/--no-sync). */
/**
* The command's full path, root name excluded: `bctx wiki table set` → `wiki table set`.
* The root program is the only Command without a parent, which is what stops the walk.
*/
export function commandPath(command: Command): string {
const parts: string[] = []
for (let c: Command | null = command; c?.parent; c = c.parent) parts.unshift(c.name())
return parts.join(' ')
}

/**
* Pull the inherited global store flags (--db/--global/--local/--project/--no-sync)
* plus the access capability this command requires.
*
* Deriving the capability here — rather than at each of the ~70 call sites — is why
* gating the CLI needed no changes inside the command handlers: every one of them
* already funnels its store access through `withDb(dbOptsFrom(command), …)`.
*/
export function dbOptsFrom(command: Command): DbOpts {
const o = command.optsWithGlobals()
const action = commandPath(command)
// commander exposes `--no-sync` as `o.sync === false`.
return {
db: o.db,
global: o.global,
local: o.local,
project: o.project,
noSync: o.sync === false,
requires: resolveCommandCapability(action),
action,
}
}

Expand Down
Loading
Loading