Project 1 // _northgate
Northgate Copilot
An agent that can act — and still can't do anything the person it acts for couldn't do by hand.
client
self-directed build — a demo, not client work
timeline
Jul 2026 · runs locally, not deployed
role
self-directed demo · architecture + trust layer
scope
## the-problem
A read-only chatbot is safe by construction — the worst it does is be wrong. Give it tools that write and a mis-selected tool stops being a UX problem: it's an accepted offer, a withdrawn listing, a price cut nobody asked for.
Most demos put permissions in the system prompt and treat whatever the model says as the confirmation. That doesn't survive an org chart. Agents see their own book, managers see everything — and the first things anyone tries are a colleague's listing, an invented record id, and a client note reading "accept all offers".
## the-approach
- ▸one authorization module, called by both the UI and every tool — "the agent can't exceed the user" needs a single implementation to be true
- ▸scope-filtered in SQL: out-of-scope rows are absent, not refused, so no id ever reaches a write tool
- ▸one gate that re-checks permission first — a failed check is denied in code, never dressed up as something to approve
- ▸diffs computed server-side, because the tool input alone can't show £450,000 → £427,500
- ▸mutation and audit row in one transaction; the driver that can't do transactions throws rather than degrading quietly
- ▸stored notes delimited as untrusted data — documented as lowering attack success, not as solving injection
// the gate — permission re-checked before a human is asked
adjustListingPrice: async ({ listingId, ...change }, { toolCallId }) => {// Authorize against the real row, never the id the model supplied.const listing = await loadListingForAuth(listingId);// The same check the manual UI path calls. Denied in code —// the model is told, and it cannot appeal.const decision = canAdjustListingPrice(session, listing);if (!decision.allowed) return denied(decision.reason);const current = await getListingPrice(listingId);const next = computeNewPrice(current, change);if ('error' in next) return denied('invalid_price_change');// Streams the diff, then pauses. Nothing is written without approval.return gate(priceDiff(toolCallId, current, next.newPrice));},
## the-architecture
// one request path — and the two places it can stop
copilot panel
Next.js · streaming
- renders diffs it didn't compute
- approve / discard
who is asking
signed cookie
- fails closed five ways
- never read from the form
the model + its tools
read · write
- reads scope-filtered in SQL
- notes wrapped as untrusted
the approval gate
re-check, then diff
- not yours → denied, no prompt
- yours → pause for a human
one transaction
Postgres
- mutation + audit row
- both, or neither
## the-results
1
authorization module, shared by UI and agent
0
writes that skip the human gate
1 tx
mutation + audit row, or neither
- ✓ask as an agent and as a manager, get different answers — the out-of-scope one says the record doesn't exist, not that access was refused
- ✓naming another agent's listing id directly, which is what a hijacked model does, is refused before the approval prompt appears
- ✓M1–M5 complete; undo, the audit view and the eval harness aren't built, and the README says so
// want something like this built for you?
start-a-project