From 448062639729d0d3ce419706a3845feb48004a56 Mon Sep 17 00:00:00 2001 From: jwfing Date: Sat, 5 Sep 2026 20:35:04 -0700 Subject: [PATCH 1/4] feat: add signed agent sessions and governance commands --- src/agent.ts | 86 ++++++++++++++++++++++++++++++++++++ src/api.ts | 7 +++ src/commands/agent-policy.ts | 47 ++++++++++++++++++++ src/commands/compute.ts | 1 + src/commands/project.ts | 3 ++ src/commands/setup.ts | 7 +++ src/config.ts | 2 + src/index.ts | 22 ++++++++- test/agent.test.ts | 57 ++++++++++++++++++++++++ 9 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/agent.ts create mode 100644 src/commands/agent-policy.ts create mode 100644 test/agent.test.ts diff --git a/src/agent.ts b/src/agent.ts new file mode 100644 index 0000000..eeae299 --- /dev/null +++ b/src/agent.ts @@ -0,0 +1,86 @@ +import { createHash, generateKeyPairSync, randomUUID, sign } from 'node:crypto' +import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { findProjectRoot, readProject } from './config.js' +import { alreadyTracked, ensureGitignore } from './gitignore.js' + +export type AgentMode = { source: 'cli-explicit' | 'cli-detected'; client: 'codex' | 'claude-code' | 'cursor' | 'unknown' } +let mode: AgentMode | null = null +export function detectAgent(explicit: boolean, env: NodeJS.ProcessEnv = process.env): AgentMode | null { + const client = env.CODEX_THREAD_ID || env.CODEX_CI === '1' ? 'codex' + : env.CLAUDECODE === '1' ? 'claude-code' + : env.CURSOR_AGENT === '1' ? 'cursor' : 'unknown' + return explicit || client !== 'unknown' ? { source: explicit ? 'cli-explicit' : 'cli-detected', client } : null +} +export function configureAgent(value: AgentMode | null): void { mode = value } +export function agentMode(): AgentMode | null { return mode } +type Session = { token: string; agentSessionId: string; projectId: string | null; expiresAt: string; privateKey: string; client: AgentMode['client']; apiUrl: string } +export type SessionApi = { apiUrl: string; request(method: string, path: string, body?: unknown): Promise } +const hash = (v: string): string => createHash('sha256').update(v).digest('hex') +export function canonicalTarget(path: string): string { + const url = new URL(path, 'https://platform.invalid') + return url.pathname + url.search +} +const guidance = 'agent session missing, expired, or for another project/environment — run `insta setup agent`' + +export async function issueAgentSession(api: SessionApi, projectId?: string): Promise { + const pair = generateKeyPairSync('ed25519') + const client = mode?.client ?? detectAgent(false)?.client ?? 'unknown' + const out = await api.request('POST', '/agent/sessions', { + projectId, client, publicKey: pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(), + }) + return { ...out, client, apiUrl: api.apiUrl.replace(/\/+$/, ''), privateKey: pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() } +} + +export async function saveAgentSession(session: Session, cwd = process.cwd()): Promise { + const root = await findProjectRoot(cwd) ?? cwd + const rel = '.insta/agent-session.json' + if (alreadyTracked(root, [rel]).length) throw new Error('agent-session.json is tracked by Git; untrack it before running insta setup agent') + ensureGitignore(root, [rel], '# Local agent credentials') + const dir = join(root, '.insta') + await mkdir(dir, { recursive: true }) + const temp = join(dir, `.agent-session-${randomUUID()}.tmp`) + // Ignore crash leftovers too; temporary files contain the same private material. + ensureGitignore(root, ['.insta/.agent-session-*.tmp']) + await writeFile(temp, JSON.stringify(session, null, 2), { mode: 0o600 }) + await rename(temp, join(root, rel)) + await chmod(join(root, rel), 0o600) +} + +export async function setupProjectAgentSession(api: SessionApi, projectId?: string): Promise { + const id = projectId ?? (await readProject())?.projectId + if (!id) return false + await saveAgentSession(await issueAgentSession(api, id)) + return true +} + +export async function loadAgentSession(apiUrl: string, projectId: string, cwd = process.cwd()): Promise { + try { + const root = await findProjectRoot(cwd) ?? cwd + const session = JSON.parse(await readFile(join(root, '.insta/agent-session.json'), 'utf8')) as Session + if (session.projectId !== projectId || session.apiUrl !== apiUrl.replace(/\/+$/, '') || !session.token || !session.privateKey + || !Number.isFinite(Date.parse(session.expiresAt)) || Date.parse(session.expiresAt) <= Date.now()) throw new Error() + return session + } catch { throw new Error(guidance) } +} + +export async function agentHeaders(api: SessionApi, method: string, path: string, rawBody: string): Promise> { + if (!mode) return {} + if (canonicalTarget(path) === '/agent/sessions' && method === 'POST') return { + 'Insta-Actor-Type': 'agent', 'Insta-Agent-Source': mode.source, 'Insta-Agent-Client': mode.client, + } + const target = canonicalTarget(path) + const match = target.match(/^\/projects\/([^/?]+)/) + // Account reads/project creation have no project policy yet. Mint a short-lived bootstrap + // assertion in memory. It cannot access project routes; never downgrade to a human request. + const session = match ? await loadAgentSession(api.apiUrl, decodeURIComponent(match[1]!)) : await issueAgentSession(api) + const timestamp = String(Math.floor(Date.now() / 1000)) + const nonce = randomUUID() + const proof = [method.toUpperCase(), target, hash(rawBody), session.agentSessionId, timestamp, nonce, mode.source, session.client].join('\n') + return { + 'Insta-Actor-Type': 'agent', 'Insta-Agent-Session': session.agentSessionId, + 'Insta-Agent-Session-Token': session.token, 'Insta-Agent-Source': mode.source, + 'Insta-Agent-Client': session.client, 'Insta-Agent-Timestamp': timestamp, + 'Insta-Agent-Nonce': nonce, 'Insta-Agent-Signature': sign(null, Buffer.from(proof), session.privateKey).toString('base64url'), + } +} diff --git a/src/api.ts b/src/api.ts index 65dfda1..7dc367d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -4,12 +4,16 @@ import { readGlobal, writeGlobal, readProject, writeProject, type GlobalConfig, import { autoResolveProject, promptChoice, type ProjectItem } from './resolve-project.js' import { die } from './util.js' import { USER_AGENT } from './version.js' +import { agentHeaders, agentMode } from './agent.js' export class ApiError extends Error { // body carries the parsed error payload for callers that branch on machine-readable errors // (e.g. template deploy's missing_variables); the message stays the human line. constructor(public status: number, msg: string, public body?: any) { super(msg); this.name = 'ApiError' } } +export class AgentApprovalRequired extends Error { + constructor(public body: any) { super(body.message ?? `approval required: ${body.approvalId}`) } +} // Store a durable insta_ key as the credential: set it as the bearer and drop any refresh token (an insta_ key never rotates; a stale one would leak to /auth/refresh on a 401). export function storeApiKeyCredential(cfg: GlobalConfig, token: string, user?: GlobalConfig['user']): void { @@ -52,6 +56,7 @@ export class ApiClient { // Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise. async request(method: string, path: string, body?: unknown, opts: { auth?: boolean } = {}): Promise { const res = await this.raw(method, path, body, opts.auth ?? true) + if (agentMode() && res.status === 202 && res.body?.status === 'approval_required') throw new AgentApprovalRequired(res.body) if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body) return res.body as T } @@ -74,6 +79,7 @@ export class ApiClient { private async fetch(method: string, path: string, body: unknown, auth: boolean): Promise { const headers: Record = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT } if (auth && this.cfg.accessToken) headers.Authorization = `Bearer ${this.cfg.accessToken}` + if (auth) Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body))) const res = await this.fetchImpl(this.apiUrl + path, { method, headers, @@ -106,6 +112,7 @@ export async function linkedProject(): Promise { return re export async function requireProject(): Promise { const p = await readProject() if (p) return p + if (agentMode()) die('agent mode requires a linked project — run `insta setup agent --project `') // One command, just works: unlinked ≠ error. Resolve the project (auto when there's one, // one-keystroke picker when several) and persist the choice so this happens once per dir. const api = await ApiClient.load() diff --git a/src/commands/agent-policy.ts b/src/commands/agent-policy.ts new file mode 100644 index 0000000..743e539 --- /dev/null +++ b/src/commands/agent-policy.ts @@ -0,0 +1,47 @@ +import { ApiClient, requireProject } from '../api.js' +import { handleApproval, info, printJson } from '../util.js' + +async function current() { + const api = await ApiClient.load() + const project = await requireProject() + const path = `/projects/${project.projectId}/agent-policy` + const out = await api.request('GET', path) + return { api, project, path, ...out } +} +export async function get(opts: { json?: boolean }) { + const { policy, agentSessionEpoch, actions } = await current() + if (opts.json) return printJson({ policy, agentSessionEpoch, actions }) + info(`agent policy: ${policy.mode}\nprotected branches: ${policy.protectedBranchIds.join(', ') || '(none)'}\nsession epoch: ${agentSessionEpoch}`) +} +async function update(change: (policy: any, state: Awaited>) => Promise | void, opts: { json?: boolean }) { + const state = await current() + await change(state.policy, state) + const result = await state.api.rawRequest('PUT', state.path, state.policy) + if (handleApproval(result, opts.json)) return + if (opts.json) return printJson(result.body) + info(`agent policy updated: ${result.body.policy.mode}`) +} +export async function set(mode: string, opts: { json?: boolean }) { + const normalized = mode.replace(/-/g, '_') + if (!['full_access', 'read_only', 'branch_developer'].includes(normalized)) throw new Error('mode must be full-access, read-only, or branch-developer') + return update(policy => { policy.mode = normalized }, opts) +} +export async function protect(branch: string, enabled: boolean, opts: { json?: boolean }) { + return update(async (policy, { api, project }) => { + const { branches } = await api.request('GET', `/projects/${project.projectId}/branches`) + const found = branches.find((b: any) => b.id === branch || b.name === branch) + if (!found) throw new Error('branch not found') + policy.protectedBranchIds = enabled ? [...new Set([...policy.protectedBranchIds, found.id])] : policy.protectedBranchIds.filter((id: string) => id !== found.id) + }, opts) +} +export async function rule(action: string, decision: string, opts: { json?: boolean }) { + if (!['allow', 'deny', 'approve'].includes(decision)) throw new Error('decision must be allow, deny, or approve') + return update(policy => { policy.branchDeveloperRules[action] = decision }, opts) +} +export async function revoke(opts: { json?: boolean }) { + const api = await ApiClient.load() + const project = await requireProject() + const out = await api.request('POST', `/projects/${project.projectId}/agent-sessions/revoke`) + if (opts.json) return printJson(out) + info(`all project agent sessions revoked (epoch ${out.agentSessionEpoch})`) +} diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 0da9dde..cdf492e 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -431,6 +431,7 @@ function operandIndices(argv: string[], from: number, to: number = argv.length): function execCommandIndex(argv: string[]): number { for (let cursor = 2; cursor < argv.length; cursor++) { const token = argv[cursor]! + if (token === '--agent') continue if (token.startsWith('-')) return -1 // a global flag, or `--`: either way not our command path return token === 'compute' && argv[cursor + 1] === 'exec' ? cursor : -1 } diff --git a/src/commands/project.ts b/src/commands/project.ts index 8f01bda..6afeee1 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,4 +1,5 @@ import { homedir } from 'node:os' +import { agentMode, setupProjectAgentSession } from '../agent.js' import { ApiClient, requireProject } from '../api.js' import { writeProject } from '../config.js' import { info, die, printJson, handleApproval, renderNextActions } from '../util.js' @@ -74,6 +75,7 @@ export async function projectCreate(name: string | undefined, opts: { org?: stri const orgId = await resolveOrg(api, opts.org) const out = await api.request('POST', `/orgs/${orgId}/projects`, { name: resolved }) await writeProject({ projectId: out.project.id, orgId, branch: out.defaultBranch.name }) + if (agentMode()) await setupProjectAgentSession(api, out.project.id) if (opts.json) { printJson({ ...out, linked: { projectId: out.project.id, orgId, branch: out.defaultBranch.name } }) } else { @@ -97,6 +99,7 @@ export async function projectList(opts: { org?: string; json?: boolean }): Promi export async function projectLink(id: string, opts: { json?: boolean } = {}): Promise { const api = await ApiClient.load() + if (agentMode()) await setupProjectAgentSession(api, id) const { project } = await api.request('GET', `/projects/${id}`) await writeProject({ projectId: project.id, orgId: project.org_id, branch: 'main' }) if (opts.json) printJson({ project, linked: { projectId: project.id, orgId: project.org_id, branch: 'main' } }) diff --git a/src/commands/setup.ts b/src/commands/setup.ts index be7ed7f..ad69eda 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -11,6 +11,7 @@ import { join } from 'node:path' import os from 'node:os' import { createInterface } from 'node:readline' import { ApiClient } from '../api.js' +import { setupProjectAgentSession } from '../agent.js' import { readPersistedGlobal, resolveEnv, type GlobalConfig } from '../config.js' import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, envFromEnvVar, isEnvName, mcpServerName, type EnvName } from '../env.js' import { info, openUrl } from '../util.js' @@ -398,6 +399,7 @@ export async function setupAgent( }, link: (id: string) => Promise = projectLink, create: (name?: string) => Promise = (n) => projectCreate(n, {}), + enroll: () => Promise = async () => setupProjectAgentSession(await ApiClient.load()), ): Promise { if (!opts.yes && !process.stdout.isTTY) { info('non-interactive shell — assuming -y') @@ -485,6 +487,11 @@ export async function setupAgent( } } } + if (loggedIn) { + if (await enroll()) info('✓ Project agent session ready (expires in 24 hours; refresh with insta setup agent)') + } else { + info(' project agent session not created — run `insta login`, then `insta setup agent`') + } // THE summary line. The restart note exists because config-file agents only read their MCP // config at startup; the skill files need no restart. const mcpOk = claude === 'new' || claude === 'existing' || others.length > 0 diff --git a/src/config.ts b/src/config.ts index bb2153c..df92610 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import { homedir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { ensureGitignore } from './gitignore.js' import { DEFAULT_ENV, ENVS, envForApiUrl, envFromEnvVar, normalizeUrl, type EnvName } from './env.js' const GLOBAL_DIR = join(homedir(), '.insta') @@ -143,5 +144,6 @@ export async function readProject(cwd = process.cwd()): Promise { const target = (await findProjectRoot(cwd)) ?? cwd await mkdir(join(target, PROJECT_DIR), { recursive: true }) + ensureGitignore(target, ['.insta/agent-session.json'], '# Local agent credentials') await writeFile(join(target, PROJECT_DIR, PROJECT_FILE), JSON.stringify(c, null, 2)) } diff --git a/src/index.ts b/src/index.ts index a466a2b..c5c5fc8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node import { Command } from 'commander' -import { ApiError } from './api.js' +import { configureAgent, detectAgent } from './agent.js' +import * as agentPolicy from './commands/agent-policy.js' +import { ApiError, AgentApprovalRequired } from './api.js' import { CliCancel, CliExit, fail, relayedExitCode } from './util.js' import { trackCommand } from './telemetry.js' import { cliVersion } from './version.js' @@ -33,6 +35,12 @@ import * as selfUpdate from './commands/upgrade.js' import * as feedbackCmd from './commands/feedback.js' function onError(e: unknown): void { + if (e instanceof AgentApprovalRequired) { + if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(e.body) + '\n') + else process.stderr.write(e.message + '\n') + process.exitCode = 2 + return + } if (e instanceof CliExit || e instanceof CliCancel) return if (e instanceof ApiError) return fail(`${e.message} (HTTP ${e.status})`) fail(e instanceof Error ? e.message : String(e)) @@ -59,6 +67,8 @@ const program = new Command() // against the subcommand's own (identically-named) option instead. program.enablePositionalOptions() program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(cliVersion()) +program.option('--agent', 'run as an agent with a verified project session and project agent policy') +program.hook('preAction', () => configureAgent(detectAgent(!!program.opts().agent))) // ---- auth ---- program.command('login').description('Log in — bare: sign in from your browser (any account type); or --email + password, --oauth , --device (headless), --api-key (headless, durable token)') @@ -354,6 +364,16 @@ ob.command('report').description('Render the local credential audit').option('-- ob.command('sync').description('Upload findings into the project timeline').action(guard(() => observe.observeSync())) // ---- policy ---- +const agentPol = program.command('agent-policy').description('Project agent access policy (separate from human governance)') +agentPol.command('get').option('--json').action(guard((o) => agentPolicy.get(o))) +agentPol.command('set ').description('full-access | read-only | branch-developer') + .option('--json').action(guard((mode, o) => agentPolicy.set(mode, o))) +agentPol.command('protect-branch ').option('--json').action(guard((branch, o) => agentPolicy.protect(branch, true, o))) +agentPol.command('unprotect-branch ').option('--json').action(guard((branch, o) => agentPolicy.protect(branch, false, o))) +agentPol.command('rule').command('set ').description('Set an unprotected-branch rule: allow | deny | approve') + .option('--json').action(guard((action, decision, o) => agentPolicy.rule(action, decision, o))) +agentPol.command('revoke-sessions').description('Revoke ALL CLI agent sessions for this project') + .option('--json').action(guard((o) => agentPolicy.revoke(o))) const pol = program.command('policy').description('Governance policy') pol.command('get').option('--json').action(guard((o) => govern.policyGet(o))) pol.command('set ').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').option('--json').action(guard((a, d, o) => govern.policySet(a, d, o))) diff --git a/test/agent.test.ts b/test/agent.test.ts new file mode 100644 index 0000000..3ee4b1d --- /dev/null +++ b/test/agent.test.ts @@ -0,0 +1,57 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { generateKeyPairSync, verify, createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { agentHeaders, configureAgent, detectAgent, loadAgentSession, saveAgentSession } from '../src/agent.js' +import { ApiClient, AgentApprovalRequired } from '../src/api.js' +import { writeProject } from '../src/config.js' +import { splitExecArgs } from '../src/commands/compute.js' + +const dirs: string[] = [] +afterEach(async () => { configureAgent(null); vi.restoreAllMocks(); await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) }) +it('only deterministic environment signals activate agent mode; explicit flag wins', () => { + expect(detectAgent(false, { CI: 'true', TERM: 'dumb' })).toBeNull() + expect(detectAgent(false, { CODEX_THREAD_ID: 'thread' })).toEqual({ source: 'cli-detected', client: 'codex' }) + expect(detectAgent(true, { CLAUDECODE: '1' })).toEqual({ source: 'cli-explicit', client: 'claude-code' }) + expect(detectAgent(false, { CURSOR_AGENT: '1' })?.client).toBe('cursor') + expect(detectAgent(true, {})?.client).toBe('unknown') +}) +it('keeps compute exec argv intact with the root agent option', () => { + const argv = ['node', 'insta', '--agent', 'compute', 'exec', 'api', '--', 'sh', '-c', 'echo hi', '--agent'] + expect(splitExecArgs(argv).command).toEqual(['sh', '-c', 'echo hi', '--agent']) +}) +it('never sends a project request as human when agent session is missing', async () => { + const dir = await mkdtemp(join(tmpdir(), 'insta-agent-')); dirs.push(dir) + vi.spyOn(process, 'cwd').mockReturnValue(dir) + configureAgent({ source: 'cli-detected', client: 'codex' }) + const fetcher = vi.fn() + const api = new ApiClient({ apiUrl: 'https://test.invalid', accessToken: 'user' }, fetcher) + await expect(api.request('POST', '/projects/p/services', { type: 'compute' })).rejects.toThrow(/insta setup agent/) + expect(fetcher).not.toHaveBeenCalled() +}) +it('stores private material with ignore/permissions and signs exact request fields from nested directories', async () => { + const dir = await mkdtemp(join(tmpdir(), 'insta-agent-')); dirs.push(dir) + await writeProject({ projectId: 'p', orgId: 'o', branch: 'main' }, dir) + const pair = generateKeyPairSync('ed25519') + const session = { token: 'signed-token', agentSessionId: 'ags_test', projectId: 'p', expiresAt: new Date(Date.now() + 60000).toISOString(), + privateKey: pair.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(), client: 'codex' as const, apiUrl: 'https://test.invalid' } + await saveAgentSession(session, dir) + expect(await readFile(join(dir, '.gitignore'), 'utf8')).toContain('.insta/agent-session.json') + if (process.platform !== 'win32') expect((await stat(join(dir, '.insta/agent-session.json'))).mode & 0o777).toBe(0o600) + expect((await loadAgentSession(session.apiUrl, 'p', join(dir, 'nested'))).agentSessionId).toBe('ags_test') + await expect(loadAgentSession('https://other.invalid', 'p', dir)).rejects.toThrow(/another project\/environment/) + await expect(loadAgentSession(session.apiUrl, 'other', dir)).rejects.toThrow() + vi.spyOn(process, 'cwd').mockReturnValue(dir) + configureAgent({ source: 'cli-explicit', client: 'codex' }) + const raw = '{"value":"a secret"}' + const path = '/projects/p/secrets/X?branch=dev&x=1&x=2' + const headers = await agentHeaders({ apiUrl: session.apiUrl, request: vi.fn() }, 'PUT', path, raw) + const proof = ['PUT', path, createHash('sha256').update(raw).digest('hex'), 'ags_test', headers['Insta-Agent-Timestamp'], headers['Insta-Agent-Nonce'], 'cli-explicit', 'codex'].join('\n') + expect(verify(null, Buffer.from(proof), pair.publicKey, Buffer.from(headers['Insta-Agent-Signature']!, 'base64url'))).toBe(true) + expect(verify(null, Buffer.from(proof.replace('dev', 'main')), pair.publicKey, Buffer.from(headers['Insta-Agent-Signature']!, 'base64url'))).toBe(false) + const fetcher = vi.fn(async () => new Response(JSON.stringify({ status: 'approval_required', approvalId: 'a1', message: 'approve a1' }), { status: 202 })) + const api = new ApiClient({ apiUrl: session.apiUrl, accessToken: 'user' }, fetcher) + await expect(api.request('PUT', '/projects/p/secrets/X', { value: 'secret' })).rejects.toBeInstanceOf(AgentApprovalRequired) + expect(fetcher).toHaveBeenCalledOnce() +}) From ee0abe2f5c98b276bf31a630716fc1ae3707db19 Mon Sep 17 00:00:00 2001 From: jwfing Date: Mon, 7 Sep 2026 10:41:24 -0700 Subject: [PATCH 2/4] refactor: retire legacy policy CLI and permanent approvals --- README.md | 11 ++++++----- src/commands/govern.ts | 22 +++------------------- src/index.ts | 7 ++----- src/telemetry.ts | 2 +- test/retired-policy.test.ts | 25 +++++++++++++++++++++++++ test/telemetry.test.ts | 2 +- 6 files changed, 38 insertions(+), 31 deletions(-) create mode 100644 test/retired-policy.test.ts diff --git a/README.md b/README.md index 5c002f4..841fb5d 100644 --- a/README.md +++ b/README.md @@ -121,10 +121,11 @@ only. Provider-minted service credentials (`DATABASE_URL`, `BUCKET_NAME`, ### Destructive actions can require approval -Reading secrets, deploying, deleting a project or branch, and changing services are -governed by a per-project policy. Where the policy says `approve`, the command stops and -prints an approval id for an admin to grant with `insta approvals approve `. Run -`insta policy get` for the live policy. +Agent requests are governed by the project's `agent-policy`; human requests use normal RBAC. +Where the agent policy says `approve`, the command stops and prints an approval id for a human +admin to grant with `insta approvals approve `. The agent then retries the unchanged request. +Run `insta --agent agent-policy get` for the live policy. The old `policy` command and approval +`--always` option have been removed. ### Agents get the same surface @@ -213,7 +214,7 @@ build never reaches a production installer. | `insta metrics` · `logs` · `events` | Service metrics; runtime logs (`--deploy` for deploy events); audit timeline | | `insta usage` · `billing` | Usage by billing dimension; `billing upgrade` · `billing portal` | | `insta approvals` | `list` · `approve` · `deny` | -| `insta policy` | `get` · `set ` | +| `insta agent-policy` | `get` · `set ` · `protect-branch` · `unprotect-branch` · `rule set ` · `revoke-sessions` | | `insta observe` | `install` · `uninstall` · `report` · `sync` — local credential audit | | `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; works logged-out | | `insta upgrade` · `autoupdate` | Update the CLI; show or set auto-update | diff --git a/src/commands/govern.ts b/src/commands/govern.ts index 874c114..835c60b 100644 --- a/src/commands/govern.ts +++ b/src/commands/govern.ts @@ -21,12 +21,12 @@ export async function approvalsList(opts: { status?: string; json?: boolean }): for (const a of approvals) info(`${a.id} ${a.action} [${a.status}] ${a.requested_at}`) } -export async function approvalsApprove(id: string, opts: { always?: boolean; json?: boolean }): Promise { +export async function approvalsApprove(id: string, opts: { json?: boolean }): Promise { const api = await ApiClient.load() const p = await requireProject() - const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/approve`, { always: !!opts.always }) + const out = await api.request('POST', `/projects/${p.projectId}/approvals/${id}/approve`, {}) if (opts.json) return printJson(out) - info(`approved ${out.approval.action} (${id})${opts.always ? ' — policy set to allow' : ''}`) + info(`approved ${out.approval.action} (${id})`) } export async function approvalsDeny(id: string, opts: { json?: boolean } = {}): Promise { @@ -36,19 +36,3 @@ export async function approvalsDeny(id: string, opts: { json?: boolean } = {}): if (opts.json) return printJson(out) info(`denied ${out.approval.action} (${id})`) } - -export async function policyGet(opts: { json?: boolean }): Promise { - const api = await ApiClient.load() - const p = await requireProject() - const { policy } = await api.request('GET', `/projects/${p.projectId}/policy`) - if (opts.json) return printJson(policy) - for (const [action, decision] of Object.entries(policy)) info(`${action}: ${decision}`) -} - -export async function policySet(action: string, decision: string, opts: { json?: boolean } = {}): Promise { - const api = await ApiClient.load() - const p = await requireProject() - const out = await api.request('PUT', `/projects/${p.projectId}/policy/${action}`, { decision }) - if (opts.json) return printJson({ action, decision, ...(out ?? {}) }) - info(`policy ${action} = ${decision}`) -} diff --git a/src/index.ts b/src/index.ts index c5c5fc8..d57e101 100644 --- a/src/index.ts +++ b/src/index.ts @@ -353,7 +353,7 @@ program.command('events').description('Show the audit + agent-event timeline').o // ---- approvals ---- const ap = program.command('approvals').description('Governance approvals (HITL)') ap.command('list').option('--status ', 'pending|granted|denied|consumed').option('--json').action(guard((o) => govern.approvalsList(o))) -ap.command('approve ').option('--always', 'also set the policy to allow').option('--json').action(guard((id, o) => govern.approvalsApprove(id, o))) +ap.command('approve ').option('--json').action(guard((id, o) => govern.approvalsApprove(id, o))) ap.command('deny ').option('--json').action(guard((id, o) => govern.approvalsDeny(id, o))) // ---- observe (local credential audit) ---- @@ -364,7 +364,7 @@ ob.command('report').description('Render the local credential audit').option('-- ob.command('sync').description('Upload findings into the project timeline').action(guard(() => observe.observeSync())) // ---- policy ---- -const agentPol = program.command('agent-policy').description('Project agent access policy (separate from human governance)') +const agentPol = program.command('agent-policy').description('Project agent access policy') agentPol.command('get').option('--json').action(guard((o) => agentPolicy.get(o))) agentPol.command('set ').description('full-access | read-only | branch-developer') .option('--json').action(guard((mode, o) => agentPolicy.set(mode, o))) @@ -374,9 +374,6 @@ agentPol.command('rule').command('set ').description('Set an .option('--json').action(guard((action, decision, o) => agentPolicy.rule(action, decision, o))) agentPol.command('revoke-sessions').description('Revoke ALL CLI agent sessions for this project') .option('--json').action(guard((o) => agentPolicy.revoke(o))) -const pol = program.command('policy').description('Governance policy') -pol.command('get').option('--json').action(guard((o) => govern.policyGet(o))) -pol.command('set ').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').option('--json').action(guard((a, d, o) => govern.policySet(a, d, o))) // ---- feedback (agent + human hurdle reports → the InstaCloud team) ---- program.command('feedback') diff --git a/src/telemetry.ts b/src/telemetry.ts index 7b4c77b..83016a9 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -46,7 +46,7 @@ const SAFE_ARGS: Record> = { 'compute always-on': { 0: ON_OFF }, 'db always-on': { 0: ON_OFF }, metrics: { 0: TARGET }, logs: { 0: TARGET }, 'template info': { 0: SLUG }, 'billing upgrade': { 0: oneOf(['pro', 'team']) }, 'approvals approve': { 0: ID }, 'approvals deny': { 0: ID }, - 'policy set': { 0: POLICY_ACTION, 1: oneOf(['allow', 'deny', 'approve']) }, autoupdate: { 0: ON_OFF }, + 'agent-policy rule set': { 0: POLICY_ACTION, 1: oneOf(['allow', 'deny', 'approve']) }, autoupdate: { 0: ON_OFF }, } const SAFE_OPTIONS: Record = { org: ID, project: ID, region: REGION, env: ENV, oauth: oneOf(['github', 'google']), diff --git a/test/retired-policy.test.ts b/test/retired-policy.test.ts new file mode 100644 index 0000000..88c5037 --- /dev/null +++ b/test/retired-policy.test.ts @@ -0,0 +1,25 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' + +const entry = fileURLToPath(new URL('../src/index.ts', import.meta.url)) +const run = (...args: string[]) => spawnSync(process.execPath, ['--import', 'tsx', entry, ...args], { encoding: 'utf8', timeout: 10000 }) + +it('only exposes agent-policy and rejects the retired policy command', () => { + const help = run('--help') + expect(help.status).toBe(0) + expect(help.stdout).toContain('agent-policy') + expect(help.stdout).not.toMatch(/^\s+policy\s/m) + const retired = run('policy', 'get') + expect(retired.status).not.toBe(0) + expect(retired.stderr).toContain("unknown command 'policy'") +}) + +it('rejects approval --always instead of promising a permanent grant', () => { + const help = run('approvals', 'approve', '--help') + expect(help.status).toBe(0) + expect(help.stdout).not.toContain('--always') + const retired = run('approvals', 'approve', 'test-id', '--always') + expect(retired.status).not.toBe(0) + expect(retired.stderr).toContain("unknown option '--always'") +}) diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 7ce1543..1e7a00d 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -101,7 +101,7 @@ describe('redaction', () => { it('keeps positionals only where the command declares an id or enum', () => { expect(redactArgs('services add', ['postgres', 'main'])).toEqual(['postgres', '[REDACTED]']) expect(redactArgs('services scale', ['compute', 'api', '3', 'us-east'])).toEqual(['compute', '[REDACTED]', '3', 'us-east']) - expect(redactArgs('policy set', ['deploy', 'approve'])).toEqual(['deploy', 'approve']) + expect(redactArgs('agent-policy rule set', ['deploy', 'approve'])).toEqual(['deploy', 'approve']) expect(redactArgs('run', ['/Users/jane/bin/dev.sh', 'x'])).toEqual(['[REDACTED]', '[REDACTED]']) expect(redactArgs('branch create', ['feat/acme-pilot'])).toEqual(['[REDACTED]']) expect(redactArgs('secrets set', ['DB_PASSWORD', 's3cret'])).toEqual(['[REDACTED]', '[REDACTED]']) From 235cfce055d002c14e4846c7bbeb25e977e7482e Mon Sep 17 00:00:00 2001 From: jwfing Date: Mon, 7 Sep 2026 11:18:23 -0700 Subject: [PATCH 3/4] bump cli version to 0.0.61 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bc5d212..b212a64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "insta", - "version": "0.0.60", + "version": "0.0.61", "type": "module", "description": "InstaCloud CLI — a thin client of the platform control-plane API.", "keywords": [ From 0c86e27daf1da82327541eda28c7c8089a1e5d84 Mon Sep 17 00:00:00 2001 From: jwfing Date: Mon, 7 Sep 2026 12:08:37 -0700 Subject: [PATCH 4/4] feat: display resolved agent policy rules --- README.md | 6 +++++- src/commands/agent-policy.ts | 22 ++++++++++++++++++---- test/agent-policy-view.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 test/agent-policy-view.test.ts diff --git a/README.md b/README.md index 841fb5d..b4d6529 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,11 @@ only. Provider-minted service credentials (`DATABASE_URL`, `BUCKET_NAME`, Agent requests are governed by the project's `agent-policy`; human requests use normal RBAC. Where the agent policy says `approve`, the command stops and prints an approval id for a human admin to grant with `insta approvals approve `. The agent then retries the unchanged request. -Run `insta --agent agent-policy get` for the live policy. The old `policy` command and approval +Run `insta --agent agent-policy get --json` for stored overrides, `defaultRules`, `effectiveRules`, +`bootstrapRules` and `ruleNotes`. Rules distinguish no affected branches (`project`), unprotected +branches and protected branches. They describe policy, not authorization: RBAC, session checks, +actual affected resources and compound actions still apply. An empty override object does not +mean rules are unavailable. Text output also lists effective rules. The old `policy` command and approval `--always` option have been removed. ### Agents get the same surface diff --git a/src/commands/agent-policy.ts b/src/commands/agent-policy.ts index 743e539..ac51546 100644 --- a/src/commands/agent-policy.ts +++ b/src/commands/agent-policy.ts @@ -6,12 +6,26 @@ async function current() { const project = await requireProject() const path = `/projects/${project.projectId}/agent-policy` const out = await api.request('GET', path) - return { api, project, path, ...out } + return { api, project, path, policy: out.policy, out } } -export async function get(opts: { json?: boolean }) { - const { policy, agentSessionEpoch, actions } = await current() - if (opts.json) return printJson({ policy, agentSessionEpoch, actions }) +export function displayPolicy(out: Record, opts: { json?: boolean }, output = { info, printJson }) { + const { info, printJson } = output + const { policy, agentSessionEpoch } = out + if (opts.json) return printJson(out) info(`agent policy: ${policy.mode}\nprotected branches: ${policy.protectedBranchIds.join(', ') || '(none)'}\nsession epoch: ${agentSessionEpoch}`) + if (out.effectiveRules) { + for (const [scope, rules] of Object.entries(out.effectiveRules)) { + info(`\n${scope}:`) + for (const [action, decision] of Object.entries(rules as Record)) info(` ${action}: ${decision}`) + } + info(`\nbootstrap: project.create = ${out.bootstrapRules?.['project.create'] ?? '(not reported)'}`) + for (const note of out.ruleNotes ?? []) info(note) + } else info('This Platform does not expose resolved rules; upgrade Platform to inspect defaults.') +} +export async function get(opts: { json?: boolean }) { + const { out } = await current() + // Forward the public response, never the internal API client (which holds credentials). + displayPolicy(out, opts) } async function update(change: (policy: any, state: Awaited>) => Promise | void, opts: { json?: boolean }) { const state = await current() diff --git a/test/agent-policy-view.test.ts b/test/agent-policy-view.test.ts new file mode 100644 index 0000000..2444dd9 --- /dev/null +++ b/test/agent-policy-view.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest' +import { displayPolicy } from '../src/commands/agent-policy.js' + +const response = { + policy: { mode: 'branch_developer', protectedBranchIds: [], branchDeveloperRules: {} }, + agentSessionEpoch: 0, actions: ['deploy'], + defaultRules: { unprotectedBranch: { deploy: 'allow' } }, + effectiveRules: { unprotectedBranch: { deploy: 'deny' }, protectedBranch: { deploy: 'deny' }, project: { deploy: 'deny' } }, + bootstrapRules: { 'project.create': 'allow' }, ruleNotes: ['Policy-only guidance, not authorization.'], +} +it('forwards all public rule fields as JSON without client-side evaluation', () => { + const values: unknown[] = [] + displayPolicy(response, { json: true }, { printJson: value => { values.push(value) }, info: () => { throw Error('unexpected text') } }) + expect(values).toEqual([response]) +}) +it('shows resolved rules and their authorization boundary in text output', () => { + const lines: string[] = [] + displayPolicy(response, {}, { info: value => { lines.push(value) }, printJson: () => { throw Error('unexpected JSON') } }) + expect(lines.join('\n')).toContain('unprotectedBranch:') + expect(lines.join('\n')).toContain('deploy: deny') + expect(lines.join('\n')).toContain('Policy-only guidance, not authorization.') +}) +it('does not invent default rules when talking to an older Platform', () => { + const lines: string[] = [] + displayPolicy({ policy: response.policy, agentSessionEpoch: 0 }, {}, { info: value => { lines.push(value) }, printJson: () => {} }) + expect(lines.join('\n')).toContain('does not expose resolved rules') +})