Skip to content

feat: add signed agent sessions and governance commands - #182

Open
jwfing wants to merge 1 commit into
mainfrom
feat/agent-governance-policy
Open

feat: add signed agent sessions and governance commands#182
jwfing wants to merge 1 commit into
mainfrom
feat/agent-governance-policy

Conversation

@jwfing

@jwfing jwfing commented Sep 6, 2026

Copy link
Copy Markdown
Member

Summary

  • Add explicit --agent mode and deterministic environment detection with fail-closed session handling.
  • Enroll project-bound signed sessions, sign requests in the shared API client, and expose agent-policy commands.
  • Relay immutable approval requests without interpreting HTTP 202 as completed work.

Validation

  • npm run typecheck
  • npm test: 1062 passed
  • Cross-module CLI/MCP/Platform governance fixture passed during implementation.

Dependencies and rollout

Requires the companion insta-platform agent-governance change before release. Companion insta-skills changes document explicit --agent invocations. No production deployment or release is included.


Summary by cubic

Adds --agent mode with signed, project-bound sessions and new agent-policy governance commands. Agent requests now fail closed when no valid session exists, and 202 approval responses surface as errors instead of being treated as completed.

Changes

  • Adds --agent flag and environment-based detection for Codex, Claude Code, and Cursor.
  • Enrolls project-bound signed sessions via insta setup agent, stored with 0600 permissions and gitignored.
  • Signs API requests with session credentials; missing/expired/wrong-project sessions cause a hard error before any request is sent.
  • Throws AgentApprovalRequired on 202 approval responses instead of returning them as success.
  • Adds agent-policy commands: get, set mode, protect/unprotect branches, set rules, revoke sessions.
  • Preserves compute exec arguments when --agent is used.

Rollout

  • Requires the companion insta-platform agent-governance change before release.
  • No production deployment included.

Written for commit 4480626. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9 issues found across 9 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/commands/project.ts">

<violation number="1" location="src/commands/project.ts:102">
P2: In `projectLink`, `setupProjectAgentSession(api, id)` runs before the `GET /projects/{id}` validates the id and before `writeProject` succeeds, so a failed link (typo'd id, no access, network error) still calls `POST /agent/sessions` and writes a signed `.insta/agent-session.json` on disk. `projectCreate` establishes the session only after a successful create+write. Move the call below the GET/link so a failed link doesn't mint/persist agent credentials; it also keeps the two commands' failure behavior consistent.</violation>
</file>

<file name="src/agent.ts">

<violation number="1" location="src/agent.ts:61">
P2: When `.insta/agent-session.json` is partially corrupted or edited, `loadAgentSession` accepts it unless token/privateKey/expiry are also missing. `agentHeaders` then sends an `undefined` session/client value and fails with an opaque server or fetch error instead of the documented `insta setup agent` guidance; validate every field used to build the proof, including `agentSessionId` and `client`, before returning the session.</violation>
</file>

<file name="src/commands/agent-policy.ts">

<violation number="1" location="src/commands/agent-policy.ts:34">
P3: When the platform policy payload lacks `protectedBranchIds`, `protect()` throws on `[...policy.protectedBranchIds, ...]` / `policy.protectedBranchIds.filter(...)`. Default the field to an array before reading it, matching the `get()` output that already treats an empty list as '(none)'.</violation>

<violation number="2" location="src/commands/agent-policy.ts:39">
P3: When the platform policy payload lacks `branchDeveloperRules`, `rule()` throws a TypeError instead of setting the rule. Guard the field before assigning: `policy.branchDeveloperRules ??= {}` so the command works on any policy shape.</violation>

<violation number="3" location="src/commands/agent-policy.ts:44">
P1: When `revoke-sessions` receives HTTP 202 outside agent mode, `request()` returns the approval envelope, so this branch reports success with an undefined epoch (or exits 0 with JSON) without revoking sessions. Use `rawRequest()` and `handleApproval()` before formatting the response, as `update()` does.</violation>
</file>

<file name="src/config.ts">

<violation number="1" location="src/config.ts:147">
P3: This unconditionally creates/updates `.gitignore` (and creates the file if absent) on every project create/link/auto-link, even for human users who never use agent sessions. The agent-session path (saveAgentSession) already adds this exact ignore entry when a session is actually written, so this forces a repository side effect on all non-agent flows. Consider adding the ignore entry only at session-write time.</violation>
</file>

<file name="src/api.ts">

<violation number="1" location="src/api.ts:59">
P3: request()'s docstring and the file-header comment say 202/approval_required returns the parsed body, but the new line throws AgentApprovalRequired for 202 in agent mode. Update the comment to note the 202-throws condition in agent mode so the contract isn't misleading.</violation>

<violation number="2" location="src/api.ts:82">
P2: In agent mode every authenticated non-project API request mints a fresh bootstrap agent session — an ED25519 keypair and a new POST /agent/sessions round-trip — because agentHeaders() calls issueAgentSession() with no caching on each fetch(). This adds keypair generation plus an extra network request to every such API call (and double-mints after a 401 refresh). Cache the bootstrap session after first issuance (or scope session minting to command start) instead of per request.</violation>
</file>

<file name="src/commands/setup.ts">

<violation number="1" location="src/commands/setup.ts:491">
P3: Under agent mode, `insta setup agent --project/--create` enrolls twice: projectLink/projectCreate already call setupProjectAgentSession(api, id) (src/commands/project.ts:78/:102), and then setup.ts calls enroll() again for the same project, issuing a second `/agent/sessions` request/keypair and overwriting the session file. Skip the project-step enrollment when the later enroll() will run, or skip enroll() when the project step already enrolled, to avoid the duplicate issuance.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

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`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When revoke-sessions receives HTTP 202 outside agent mode, request() returns the approval envelope, so this branch reports success with an undefined epoch (or exits 0 with JSON) without revoking sessions. Use rawRequest() and handleApproval() before formatting the response, as update() does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/agent-policy.ts, line 44:

<comment>When `revoke-sessions` receives HTTP 202 outside agent mode, `request()` returns the approval envelope, so this branch reports success with an undefined epoch (or exits 0 with JSON) without revoking sessions. Use `rawRequest()` and `handleApproval()` before formatting the response, as `update()` does.</comment>

<file context>
@@ -0,0 +1,47 @@
+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})`)
</file context>

Comment thread src/commands/project.ts

export async function projectLink(id: string, opts: { json?: boolean } = {}): Promise<void> {
const api = await ApiClient.load()
if (agentMode()) await setupProjectAgentSession(api, id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: In projectLink, setupProjectAgentSession(api, id) runs before the GET /projects/{id} validates the id and before writeProject succeeds, so a failed link (typo'd id, no access, network error) still calls POST /agent/sessions and writes a signed .insta/agent-session.json on disk. projectCreate establishes the session only after a successful create+write. Move the call below the GET/link so a failed link doesn't mint/persist agent credentials; it also keeps the two commands' failure behavior consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/project.ts, line 102:

<comment>In `projectLink`, `setupProjectAgentSession(api, id)` runs before the `GET /projects/{id}` validates the id and before `writeProject` succeeds, so a failed link (typo'd id, no access, network error) still calls `POST /agent/sessions` and writes a signed `.insta/agent-session.json` on disk. `projectCreate` establishes the session only after a successful create+write. Move the call below the GET/link so a failed link doesn't mint/persist agent credentials; it also keeps the two commands' failure behavior consistent.</comment>

<file context>
@@ -97,6 +99,7 @@ export async function projectList(opts: { org?: string; json?: boolean }): Promi
 
 export async function projectLink(id: string, opts: { json?: boolean } = {}): Promise<void> {
   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' })
</file context>

Comment thread src/agent.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When .insta/agent-session.json is partially corrupted or edited, loadAgentSession accepts it unless token/privateKey/expiry are also missing. agentHeaders then sends an undefined session/client value and fails with an opaque server or fetch error instead of the documented insta setup agent guidance; validate every field used to build the proof, including agentSessionId and client, before returning the session.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agent.ts, line 61:

<comment>When `.insta/agent-session.json` is partially corrupted or edited, `loadAgentSession` accepts it unless token/privateKey/expiry are also missing. `agentHeaders` then sends an `undefined` session/client value and fails with an opaque server or fetch error instead of the documented `insta setup agent` guidance; validate every field used to build the proof, including `agentSessionId` and `client`, before returning the session.</comment>

<file context>
@@ -0,0 +1,86 @@
+  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
</file context>

Comment thread src/api.ts
private async fetch(method: string, path: string, body: unknown, auth: boolean): Promise<RawResult> {
const headers: Record<string, string> = { '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)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: In agent mode every authenticated non-project API request mints a fresh bootstrap agent session — an ED25519 keypair and a new POST /agent/sessions round-trip — because agentHeaders() calls issueAgentSession() with no caching on each fetch(). This adds keypair generation plus an extra network request to every such API call (and double-mints after a 401 refresh). Cache the bootstrap session after first issuance (or scope session minting to command start) instead of per request.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api.ts, line 82:

<comment>In agent mode every authenticated non-project API request mints a fresh bootstrap agent session — an ED25519 keypair and a new POST /agent/sessions round-trip — because agentHeaders() calls issueAgentSession() with no caching on each fetch(). This adds keypair generation plus an extra network request to every such API call (and double-mints after a 401 refresh). Cache the bootstrap session after first issuance (or scope session minting to command start) instead of per request.</comment>

<file context>
@@ -74,6 +79,7 @@ export class ApiClient {
   private async fetch(method: string, path: string, body: unknown, auth: boolean): Promise<RawResult> {
     const headers: Record<string, string> = { '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,
</file context>

Comment thread src/config.ts
export async function writeProject(c: ProjectConfig, cwd = process.cwd()): Promise<void> {
const target = (await findProjectRoot(cwd)) ?? cwd
await mkdir(join(target, PROJECT_DIR), { recursive: true })
ensureGitignore(target, ['.insta/agent-session.json'], '# Local agent credentials')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This unconditionally creates/updates .gitignore (and creates the file if absent) on every project create/link/auto-link, even for human users who never use agent sessions. The agent-session path (saveAgentSession) already adds this exact ignore entry when a session is actually written, so this forces a repository side effect on all non-agent flows. Consider adding the ignore entry only at session-write time.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/config.ts, line 147:

<comment>This unconditionally creates/updates `.gitignore` (and creates the file if absent) on every project create/link/auto-link, even for human users who never use agent sessions. The agent-session path (saveAgentSession) already adds this exact ignore entry when a session is actually written, so this forces a repository side effect on all non-agent flows. Consider adding the ignore entry only at session-write time.</comment>

<file context>
@@ -143,5 +144,6 @@ export async function readProject(cwd = process.cwd()): Promise<ProjectConfig |
 export async function writeProject(c: ProjectConfig, cwd = process.cwd()): Promise<void> {
   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))
 }
</file context>

Comment thread src/api.ts
// Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
async request<T = any>(method: string, path: string, body?: unknown, opts: { auth?: boolean } = {}): Promise<T> {
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: request()'s docstring and the file-header comment say 202/approval_required returns the parsed body, but the new line throws AgentApprovalRequired for 202 in agent mode. Update the comment to note the 202-throws condition in agent mode so the contract isn't misleading.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api.ts, line 59:

<comment>request()'s docstring and the file-header comment say 202/approval_required returns the parsed body, but the new line throws AgentApprovalRequired for 202 in agent mode. Update the comment to note the 202-throws condition in agent mode so the contract isn't misleading.</comment>

<file context>
@@ -52,6 +56,7 @@ export class ApiClient {
   // Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
   async request<T = any>(method: string, path: string, body?: unknown, opts: { auth?: boolean } = {}): Promise<T> {
     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
</file context>

Comment thread src/commands/setup.ts
}
}
if (loggedIn) {
if (await enroll()) info('✓ Project agent session ready (expires in 24 hours; refresh with insta setup agent)')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Under agent mode, insta setup agent --project/--create enrolls twice: projectLink/projectCreate already call setupProjectAgentSession(api, id) (src/commands/project.ts:78/:102), and then setup.ts calls enroll() again for the same project, issuing a second /agent/sessions request/keypair and overwriting the session file. Skip the project-step enrollment when the later enroll() will run, or skip enroll() when the project step already enrolled, to avoid the duplicate issuance.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/setup.ts, line 491:

<comment>Under agent mode, `insta setup agent --project/--create` enrolls twice: projectLink/projectCreate already call setupProjectAgentSession(api, id) (src/commands/project.ts:78/:102), and then setup.ts calls enroll() again for the same project, issuing a second `/agent/sessions` request/keypair and overwriting the session file. Skip the project-step enrollment when the later enroll() will run, or skip enroll() when the project step already enrolled, to avoid the duplicate issuance.</comment>

<file context>
@@ -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`')
</file context>

}
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When the platform policy payload lacks branchDeveloperRules, rule() throws a TypeError instead of setting the rule. Guard the field before assigning: policy.branchDeveloperRules ??= {} so the command works on any policy shape.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/agent-policy.ts, line 39:

<comment>When the platform policy payload lacks `branchDeveloperRules`, `rule()` throws a TypeError instead of setting the rule. Guard the field before assigning: `policy.branchDeveloperRules ??= {}` so the command works on any policy shape.</comment>

<file context>
@@ -0,0 +1,47 @@
+}
+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 }) {
</file context>
Suggested change
return update(policy => { policy.branchDeveloperRules[action] = decision }, opts)
return update(policy => { (policy.branchDeveloperRules ??= {})[action] = decision }, opts)

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When the platform policy payload lacks protectedBranchIds, protect() throws on [...policy.protectedBranchIds, ...] / policy.protectedBranchIds.filter(...). Default the field to an array before reading it, matching the get() output that already treats an empty list as '(none)'.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/agent-policy.ts, line 34:

<comment>When the platform policy payload lacks `protectedBranchIds`, `protect()` throws on `[...policy.protectedBranchIds, ...]` / `policy.protectedBranchIds.filter(...)`. Default the field to an array before reading it, matching the `get()` output that already treats an empty list as '(none)'.</comment>

<file context>
@@ -0,0 +1,47 @@
+    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)
+}
</file context>
Suggested change
policy.protectedBranchIds = enabled ? [...new Set([...policy.protectedBranchIds, found.id])] : policy.protectedBranchIds.filter((id: string) => id !== found.id)
const ids = policy.protectedBranchIds ?? []
policy.protectedBranchIds = enabled ? [...new Set([...ids, found.id])] : ids.filter((id: string) => id !== found.id)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant