Skip to content
Open
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ Tasks tracked as markdown in `tasks/` (backlog -> active -> archive). See `tasks
Use the `/changelog` skill for structured queries.

- byo-mcp-servers: Bring-your-own MCP endpoints. Users store `{name, url, authType, token, enabled}` at personal (`/api/mcp-connections`) or project (`/api/projects/:projectId/mcp-connections`) scope; SAM injects them into every agent session alongside `sam-mcp`. Both URL and token are AES-256-GCM encrypted and never returned by a read path (several providers issue pre-signed URLs with the credential in the URL, so the URL is a secret; `url_host` is the display value). Migration `0120_mcp_connections`. `buildSessionMcpServers` is the single composition point, called by `agent-session-bootstrap.ts` (covers VM + cf-container, rule 61) and the manual workspace agent-session route; the anonymous trial path is deliberately pinned to `sam-mcp` only. Resolution skips-and-warns per row so one bad connection cannot brick session start (rules 41/50). vm-agent: `McpServerEntry.Name` (additive, rule 54) plus `ResolveMcpServerNames` as the single naming source of truth — it replaced three drifted copies, one of which named a lone server `sam-mcp-0` for Vibe and `sam-mcp` everywhere else; persistence `migrateV12`. Codex's startup precondition, which required a bearer token for EVERY injected server, is now scoped to the reserved `sam-mcp` entry so a no-auth connection cannot break all Codex sessions. UI: Settings → MCP Servers (personal) and Project Settings → Runtime (project), one shared `McpServersManager`. v1 is bearer/none auth and personal/project scope only; custom headers and profile/skill attachment are tracked in idea `01M0QDASJCK3YWVX1GETZTSFWZ`. Limits: `MAX_MCP_CONNECTIONS_PER_SCOPE`, `MCP_CONNECTION_URL_MAX_BYTES`, `MCP_CONNECTION_TOKEN_MAX_BYTES`.
- policy-lifecycle-controls: Project policies gain a shelf life so one-shot workflow policies stop being injected into every session forever. Additive DO migration `034-policy-lifecycle-controls` adds `expires_at INTEGER` (nullable — `NULL` means never expires, preserving every existing policy's behaviour) and `scope TEXT NOT NULL DEFAULT 'always'` (`'always' | 'task'`). `getActivePolicies` filters at READ time (`active = 1 AND (expires_at IS NULL OR expires_at > ?)`) — no sweep, no cron; the row is retained and stays `active` so `get_policy` / `list_policies` / the Policies tab still show a human why a policy stopped applying, and the per-project cap COUNT excludes expired rows. A `scope: 'task'` policy MUST carry an `expiresAt`, enforced by one shared `validatePolicyLifecycle` at all three write boundaries (MCP `policy-tools.ts`, REST `routes/policies.ts`, sam-session `tools/add-policy.ts`) plus a DO-level choke point. `add_policy` / `update_policy` accept `scope` + `expiresAt`; `expiresAt: null` on update clears an expiry. Expiring policies render an inline `(task-scoped, expires YYYY-MM-DD)` annotation in `policyDirectives`, and the capture instruction now tells agents to scope dated work. New limit `POLICY_MAX_EXPIRY_MS` (default 365 days). Also hardened `scripts/quality/check-do-migration-safety.ts`, which extracted only backtick and single-quoted SQL — a `sql.exec("DROP TABLE ...")` in double quotes was invisible to the gate and reported PASS.
- report-issue-idea-flow: Hosted "Report an Issue" flow that groups reports into private feedback incidents and creates/updates a linked draft Idea in the effective private feedback project (Admin → Integrations runtime setting, falling back to `PLATFORM_FEEDBACK_PROJECT_ID`). Two entry points: SessionHeader action row (expanded) and ErrorBoundary crash screen. Users explicitly consent before technical refs (sessionId, taskId, nodeId) are attached. Server-side cross-tenant ref authorization validates project membership before storing references. User text sanitized with secret/PII redaction and fenced with provenance markers. Feature auto-hidden when no effective feedback project exists or it does not reference an existing project in the current deployment database. Configurable limits: `REPORT_ISSUE_TITLE_MAX_LENGTH`, `REPORT_ISSUE_DESCRIPTION_MAX_LENGTH`, `REPORT_ISSUE_CONTENT_MAX_LENGTH`.
- claude-opus-5-model-catalog: Claude Opus 5 (`claude-opus-5`, released 2026-07-24, $5/$25 per MTok, native 1M context) added to both canonical model lists — `CLAUDE_MODELS` dropdown catalog and `PLATFORM_AI_MODELS` proxy allowlist/pricing. Retired `claude-sonnet-4-20250514` pruned; stale defaults bumped to `claude-sonnet-5` (`DEFAULT_SAM_MODEL` — was pointing at the retired model, `DEFAULT_AI_PROXY_ANTHROPIC_MODEL`, `DEFAULT_TRIAL_MODEL_PRODUCTION`). New regression tests pin retired-Anthropic-model exclusion and default↔catalog registration; process rule `.claude/rules/52-model-catalog-lifecycle.md`.
- dispatch-task-runtime-routing: MCP `dispatch_task` now honors explicit and skill/profile `runtime` selection. Explicit `cf-container` decisions launch task-mode Instant sessions asynchronously without VM credential/quota gates or duplicate chat persistence; the Instant branch re-verifies repository owner access (`requireRepositoryOwnerAccess`) before launching, matching the VM branch; async Instant launch failures mark the task failed via the shared queued-guarded transition (`markQueuedTaskFailed`) instead of stalling `queued` until the stuck-task cron; VM-only arguments conflict with container runtime instead of silently downgrading; responses expose the effective runtime and decision reason, and `get_task_details` returns the task's chat `sessionId` once the async session exists.
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/durable-objects/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,34 @@ export const MIGRATIONS: Migration[] = [
`);
},
},
{
name: '034-policy-lifecycle-controls',
run: (sql) => {
// Policy lifecycle controls — give policies a shelf life and a scope so
// one-shot workflow policies stop being injected into every session forever.
//
// STRICTLY ADDITIVE (rules 31 / 63): two ALTER TABLE ADD COLUMN statements, no
// table recreation, no DROP. A Durable Object has no D1-style time-travel
// recovery, so a drop-and-restore here would be unrecoverable.
//
// Do not put quoted SQL fragments in these comments: the migration safety
// scanner extracts every quoted literal in this file — backtick, single AND
// double — and validates each as a statement, so inline-code prose is checked
// as if it were real SQL.
//
// Both defaults reproduce the pre-migration behavior exactly, so every policy
// that already exists keeps applying unchanged:
// expires_at NULL -> never expires (what every policy does today)
// scope 'always' -> standing project policy
//
// `scope` gets no CHECK constraint, matching `category` / `source` in
// migration 019 — SQLite cannot add a CHECK via ALTER TABLE, and the value is
// validated at the write boundary by `validatePolicyLifecycle` in
// packages/shared/src/constants/policies.ts.
sql.exec('ALTER TABLE project_policies ADD COLUMN expires_at INTEGER');
sql.exec("ALTER TABLE project_policies ADD COLUMN scope TEXT NOT NULL DEFAULT 'always'");
},
},
];

/**
Expand Down
10 changes: 8 additions & 2 deletions apps/api/src/durable-objects/project-data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1564,13 +1564,15 @@

// --- Project Policies (Phase 4: Policy Propagation) ---

async createPolicy(

Check warning on line 1567 in apps/api/src/durable-objects/project-data/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Async method 'createPolicy' has too many parameters (8). Maximum allowed is 7.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AaAxEMMGvKLTf8kMLLWf&open=AaAxEMMGvKLTf8kMLLWf&pullRequest=1893
category: import('@simple-agent-manager/shared').PolicyCategory,
title: string,
content: string,
source: import('@simple-agent-manager/shared').PolicySource,
sourceSessionId: string | null,
confidence: number
confidence: number,
scope: import('@simple-agent-manager/shared').PolicyScope = 'always',
expiresAt: number | null = null
) {
const result = policies.createPolicy(
this.sql,
Expand All @@ -1580,7 +1582,9 @@
content,
source,
sourceSessionId,
confidence
confidence,
scope,
expiresAt
);
this.broadcastEvent('policy.created', { id: result.id, category, title });
return result;
Expand All @@ -1602,6 +1606,8 @@
category?: import('@simple-agent-manager/shared').PolicyCategory;
active?: boolean;
confidence?: number;
scope?: import('@simple-agent-manager/shared').PolicyScope;
expiresAt?: number | null;
}
) {
const result = policies.updatePolicy(this.sql, policyId, updates);
Expand Down
115 changes: 99 additions & 16 deletions apps/api/src/durable-objects/project-data/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* stated by humans or inferred by agents. They are stored in the ProjectData DO
* SQLite and injected into agent instructions via get_instructions.
*/
import type { PolicyCategory, PolicySource } from '@simple-agent-manager/shared';
import type { PolicyCategory, PolicyScope, PolicySource } from '@simple-agent-manager/shared';
import { DEFAULT_POLICY_MAX_PER_PROJECT } from '@simple-agent-manager/shared';

import { parseCountCnt, parsePolicyRow } from './row-schemas';
Expand All @@ -18,6 +18,41 @@ function getMaxPolicies(env: Env): number {
return Number(env.POLICY_MAX_PER_PROJECT) || DEFAULT_POLICY_MAX_PER_PROJECT;
}

/**
* The "still applies right now" predicate.
*
* A policy applies when it is active AND either has no expiry or has not reached
* it yet. Expiry is evaluated at READ time — there is deliberately no sweep, cron,
* or alarm that flips expired rows to inactive (rule 47: no new control loop for
* something a WHERE clause answers for free).
*
* Note what this predicate is NOT applied to: `getPolicy` and `listPolicies` both
* keep returning expired policies, because a human needs to be able to see that a
* policy exists and why it stopped applying. Only the agent-injection read filters.
*/
const APPLIES_NOW_SQL = 'active = 1 AND (expires_at IS NULL OR expires_at > ?)';

/**
* The two statements that apply the predicate above, composed ONCE here at module
* scope rather than interpolated at the `sql.exec()` call sites.
*
* Both forms would execute identically, but composing here means the string handed
* to `sql.exec()` is a fixed load-time constant with no call-site interpolation at
* all — which is what `scripts/quality/ast-checks.ts` (`sql-injection`,
* `parameterized-sql`) requires, and it keeps every executed statement greppable in
* one place. The predicate itself stays single-sourced, so the cap count and the
* injection read cannot drift apart.
*
* Placeholder order matters: the `?` inside APPLIES_NOW_SQL binds `now`, and the
* trailing `LIMIT ?` binds the cap.
*/
const COUNT_APPLIES_NOW_SQL = `SELECT COUNT(*) as cnt FROM project_policies WHERE ${APPLIES_NOW_SQL}`;

const SELECT_APPLIES_NOW_SQL = `SELECT * FROM project_policies
WHERE ${APPLIES_NOW_SQL}
ORDER BY category ASC, created_at ASC
LIMIT ?`;

// ─── Policy CRUD ────────────────────────────────────────────────────────────

export function createPolicy(
Expand All @@ -29,21 +64,43 @@ export function createPolicy(
source: PolicySource,
sourceSessionId: string | null,
confidence: number,
scope: PolicyScope = 'always',
expiresAt: number | null = null,
): { id: string; now: number } {
const now = Date.now();

// Final server-side guard on the scope/expiry invariant — see updatePolicy.
if (scope === 'task' && expiresAt === null) {
throw new Error(
"a task-scoped policy must set expiresAt so it cannot outlive the work it was captured for (use scope 'always' for a standing policy)",
);
}

// The per-project cap counts only policies that still apply. An expired policy is
// inert — it is injected into nothing — so letting it consume cap headroom would
// slowly wedge projects that use short-lived task-scoped policies.
//
// KNOWN CHARACTERISTIC: because an expired row is deliberately retained (so a human
// can still see in `list_policies` / the UI that the policy existed and when it
// lapsed), total row count is bounded by human/agent write volume rather than by
// this cap. Adding a naive hard total ceiling would be worse than the growth it
// prevents: `removePolicy` is a soft delete, so a project that hit the ceiling
// could never write a policy again. Sizing that ceiling against a real retention
// or hard-delete path is tracked in
// tasks/backlog/2026-08-23-policy-row-retention-bound.md (rule 42 — tracked, not silent).
const count = parseCountCnt(
sql.exec('SELECT COUNT(*) as cnt FROM project_policies WHERE active = 1').toArray()[0],
sql.exec(COUNT_APPLIES_NOW_SQL, now).toArray()[0],
'policy_count',
);
if (count >= getMaxPolicies(env)) {
throw new Error(`Maximum active policies per project (${getMaxPolicies(env)}) reached`);
}

const id = generateId();
const now = Date.now();
sql.exec(
`INSERT INTO project_policies (id, category, title, content, source, source_session_id, confidence, active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
id, category, title, content, source, sourceSessionId, confidence, now, now,
`INSERT INTO project_policies (id, category, title, content, source, source_session_id, confidence, active, scope, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)`,
id, category, title, content, source, sourceSessionId, confidence, scope, expiresAt, now, now,
);
return { id, now };
}
Expand Down Expand Up @@ -105,22 +162,44 @@ export function updatePolicy(
category?: PolicyCategory;
active?: boolean;
confidence?: number;
scope?: PolicyScope;
expiresAt?: number | null;
},
): boolean {
const existing = getPolicy(sql, policyId);
if (!existing) return false;

const now = Date.now();

// Final server-side guard on the scope/expiry invariant, evaluated against the
// merged post-write state. Both callers (MCP tool handler and REST route) already
// validate with the same shared helper and produce a friendlier message; this is
// the choke point that a future third writer cannot bypass (rules 44 / 51).
const effectiveScope = updates.scope ?? existing.scope;
const effectiveExpiresAt =
updates.expiresAt !== undefined ? updates.expiresAt : existing.expiresAt;
if (effectiveScope === 'task' && effectiveExpiresAt === null) {
throw new Error(
"a task-scoped policy must set expiresAt so it cannot outlive the work it was captured for (use scope 'always' for a standing policy)",
);
}

// Update all columns using COALESCE-style approach to avoid dynamic SQL.
// Each field falls back to its existing value when not provided.
//
// `expiresAt` deliberately uses an explicit `!== undefined` check rather than the
// `??` idiom used above: `null` is a MEANINGFUL value here (it clears an expiry
// and makes the policy permanent), and `updates.expiresAt ?? existing.expiresAt`
// would silently treat "clear this expiry" as "leave it alone".
sql.exec(
'UPDATE project_policies SET title = ?, content = ?, category = ?, active = ?, confidence = ?, updated_at = ? WHERE id = ?',
'UPDATE project_policies SET title = ?, content = ?, category = ?, active = ?, confidence = ?, scope = ?, expires_at = ?, updated_at = ? WHERE id = ?',
updates.title ?? existing.title,
updates.content ?? existing.content,
updates.category ?? existing.category,
updates.active !== undefined ? (updates.active ? 1 : 0) : (existing.active ? 1 : 0),
updates.confidence ?? existing.confidence,
updates.scope ?? existing.scope,
updates.expiresAt !== undefined ? updates.expiresAt : existing.expiresAt,
now,
policyId,
);
Expand All @@ -140,17 +219,21 @@ export function removePolicy(sql: SqlStorage, policyId: string): boolean {
}

/**
* Get all active policies for injection into agent instructions.
* Get the policies that currently apply, for injection into agent instructions.
* Ordered by category then created_at for consistent presentation.
*
* Expired policies are filtered out HERE rather than by a background sweep, so a
* one-shot policy stops costing tokens in every session the moment it lapses,
* with no new control loop to own (rule 47). The row stays in the table and stays
* `active`, so `get_policy` and `list_policies` can still show a human that it
* existed and when it lapsed.
*/
export function getActivePolicies(sql: SqlStorage, env: Env): ReturnType<typeof parsePolicyRow>[] {
export function getActivePolicies(
sql: SqlStorage,
env: Env,
now: number = Date.now(),
): ReturnType<typeof parsePolicyRow>[] {
const max = getMaxPolicies(env);
const rows = sql.exec(
`SELECT * FROM project_policies
WHERE active = 1
ORDER BY category ASC, created_at ASC
LIMIT ?`,
max,
).toArray();
const rows = sql.exec(SELECT_APPLIES_NOW_SQL, now, max).toArray();
return rows.map((row) => parsePolicyRow(row));
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ const PolicyRowSchema = v.object({
source_session_id: v.nullable(v.string()),
confidence: v.number(),
active: v.union([v.number(), v.boolean()]),
// Lifecycle columns added by DO migration 034. Both are tolerated as absent so a
// row read through a stale/partial schema degrades to the pre-migration defaults
// instead of throwing and taking the whole read down (rule 50).
expires_at: v.optional(v.nullable(v.number())),
scope: v.optional(v.nullable(v.string())),
created_at: v.number(),
updated_at: v.number(),
});
Expand All @@ -28,6 +33,8 @@ export function parsePolicyRow(row: unknown): {
sourceSessionId: string | null;
confidence: number;
active: boolean;
scope: string;
expiresAt: number | null;
createdAt: number;
updatedAt: number;
} {
Expand All @@ -41,6 +48,8 @@ export function parsePolicyRow(row: unknown): {
sourceSessionId: r.source_session_id,
confidence: r.confidence,
active: r.active === 1 || r.active === true,
scope: r.scope ?? 'always',
expiresAt: r.expires_at ?? null,
createdAt: r.created_at,
updatedAt: r.updated_at,
};
Expand Down
Loading
Loading