Skip to content
Draft
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
46 changes: 33 additions & 13 deletions .github/workflows/web-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,11 @@ jobs:
] LOOP
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.%I TO forge_app_test', table_name);
END LOOP;
REVOKE ALL ON TABLE public.execution_outcomes FROM forge_app_test;
GRANT SELECT, INSERT, UPDATE ON TABLE public.execution_outcomes TO forge_app_test;
REVOKE ALL ON TABLE public.execution_outcomes, public.operation_runs,
public.operation_run_events FROM forge_app_test;
GRANT SELECT, INSERT, UPDATE ON TABLE public.execution_outcomes,
public.operation_runs TO forge_app_test;
GRANT SELECT, INSERT ON TABLE public.operation_run_events TO forge_app_test;
GRANT USAGE, SELECT ON SEQUENCE public.task_logs_sequence_seq TO forge_app_test;
END;
$grant_s4_application_acl$;
Expand Down Expand Up @@ -509,7 +512,7 @@ jobs:
'app_settings', 'task_questions'
];
operation_ledger_tables constant text[] := ARRAY[
'execution_outcomes'
'execution_outcomes', 'operation_runs', 'operation_run_events'
];
protected_tables constant text[] := ARRAY[
'forge_release_signer_keys', 'forge_release_signer_key_lifecycle_audits',
Expand Down Expand Up @@ -541,22 +544,37 @@ jobs:
IF EXISTS (
SELECT 1 FROM pg_catalog.pg_tables
WHERE schemaname = 'public'
AND tablename <> ALL (ordinary_tables || operation_ledger_tables || protected_tables || projection_tables)
AND tablename <> ALL (
ordinary_tables || operation_ledger_tables || protected_tables || projection_tables
)
) THEN
RAISE EXCEPTION 'A public table is missing from the closed application ACL inventory';
END IF;
FOREACH table_name IN ARRAY ordinary_tables LOOP
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.%I TO forge_app_test', table_name);
END LOOP;
REVOKE ALL ON TABLE public.execution_outcomes FROM forge_app_test;
GRANT SELECT, INSERT, UPDATE ON TABLE public.execution_outcomes TO forge_app_test;
FOREACH table_privilege IN ARRAY ARRAY['DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER'] LOOP
IF has_table_privilege(
'forge_app_test', 'public.execution_outcomes', table_privilege
) THEN
RAISE EXCEPTION 'ordinary app has unexpected % on operation ledger table public.execution_outcomes',
table_privilege;
END IF;
FOREACH table_name IN ARRAY operation_ledger_tables LOOP
EXECUTE format('REVOKE ALL ON TABLE public.%I FROM forge_app_test', table_name);
END LOOP;
GRANT SELECT, INSERT, UPDATE ON TABLE public.execution_outcomes,
public.operation_runs TO forge_app_test;
GRANT SELECT, INSERT ON TABLE public.operation_run_events TO forge_app_test;
FOREACH table_name IN ARRAY operation_ledger_tables LOOP
FOREACH table_privilege IN ARRAY ARRAY[
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER'
] LOOP
IF has_table_privilege(
'forge_app_test', format('public.%I', table_name), table_privilege
) IS DISTINCT FROM (
(table_name IN ('execution_outcomes', 'operation_runs')
AND table_privilege IN ('SELECT', 'INSERT', 'UPDATE'))
OR (table_name = 'operation_run_events'
AND table_privilege IN ('SELECT', 'INSERT'))
) THEN
RAISE EXCEPTION 'ordinary app has unexpected % on operation ledger table public.%',
table_privilege, table_name;
END IF;
END LOOP;
END LOOP;
FOREACH table_name IN ARRAY protected_tables LOOP
EXECUTE format('REVOKE ALL ON TABLE public.%I FROM forge_app_test', table_name);
Expand Down Expand Up @@ -709,6 +727,8 @@ jobs:
run: npm run test:unit:zero-skip
env:
FORGE_EPIC_172_REQUIRE_POSTGRES_TEST: '1'
FORGE_OPERATION_LEDGER_REQUIRE_POSTGRES_TEST: '1'
FORGE_OPERATION_LEDGER_POSTGRES_ADMIN_TEST_URL: postgresql://forge_e2e:forge@localhost:5432/forge_epic_172_ci_test
FORGE_EPIC_172_TEST_APP_DATABASE_URL: postgresql://forge_app_test:forge_app_test@localhost:5432/forge_epic_172_ci_test
FORGE_EPIC_172_TEST_WRITER_DATABASE_URL: postgresql://forge_release_evidence_writer:forge_writer_test@localhost:5432/forge_epic_172_ci_test
FORGE_EPIC_172_TEST_TRANSITION_DATABASE_URL: postgresql://forge_release_transition:forge_transition_test@localhost:5432/forge_epic_172_ci_test
Expand Down
127 changes: 127 additions & 0 deletions docs/adr/0011-deterministic-operation-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# ADR 0011: Deterministic operation catalog

## Status

Accepted.

## Decision

Forge v1 exposes a small, code-owned catalog of typed operations. An agent may
select an operation id and version, provide the exact declared input object,
state an informational reason, and name the required capability. It may not
provide a working directory, path, command, argument list, server name, or tool
name. The reason is fingerprinted for audit but cannot change execution or
idempotency.

The initial catalog contains three read-only operations: Git status, Git diff
summary, and current Git branch. Their adapters have
fixed actions and receive project and task scope only from trusted Forge
context. They do not use a shell, write repository files, materialize generated
files, mutate GitHub or MCP state, or trigger retries.

The production composition entry point is `executeTrustedOperation` in
`web/worker/operations/context.ts`. It joins the task to its project in
PostgreSQL, reads the current project path, root-binding revision, and project
update revision, and validates and canonicalizes an existing root through the
same project-path boundary used by normal Forge execution. Callers cannot
provide a project id or repository root. Direct construction of
`TrustedOperationContext` is reserved for focused tests and already-trusted
internal composition.

The caller also cannot supply capabilities, ceilings, or a policy version.
Forge derives them from the current approved or running task, the linked work
package, current project revisions, and the existing effective filesystem-grant
authority. Repository reads require an approved effective
`filesystem.project.read` project grant in `always_allow` mode. Forge rejects
`allow_once` grants because this executor does not atomically consume them.
Immediately before Git starts, Forge reloads that authority and re-canonicalizes
the current project root; any task, package, grant, revision, or root change
fails closed.

The wrapper verifies every supplied work-package, agent-run, and task-attempt
link against the authoritative task. It always composes repository reads from
Forge's bounded command runner and command-audit writer. Model output and operation request fields cannot
replace these production adapters. Each successful repository operation must
carry its command-audit UUID as evidence; exit code zero alone is insufficient.
If Git fails or cancellation wins after the command starts, the failed phase and
canonical outcome retain that audit UUID without copying raw command output into
the operation ledger.

The diff-summary operation uses the exact argument list
`git diff --no-ext-diff --no-textconv --stat --`. The bounded command runner
rejects weaker variants, so repository attributes cannot select an external
diff helper or text-conversion program.

Every request is checked in this order: request schema, exact inputs, catalog
version, trusted scope, existing policy ceilings, preflight, fixed adapter, and
deterministic output verification. A successful adapter call is not enough to
complete a run; verification must pass. Unknown operations, changed versions,
missing roots, denied capabilities, timeouts, malformed evidence references,
and invalid output fail closed.

The `operation_runs` ledger stores the exact definition version, definition and
scope digests, request/input/reason fingerprints, policy decision, status, and
the linked canonical execution outcome from ADR 0010. Raw model inputs and
reasons are not stored there. Phase events are append-only and have fixed
sequence numbers from request validation through outcome. Starting a run uses a
unique task/idempotency key; final outcome creation, the outcome phase event,
and run terminalization commit in one database transaction.

The terminal run stores a digest of the normalized canonical outcome. Replay
recomputes the digest from the linked outcome row and fails closed if that row
changed. The database permits only the explicit phase graph: validation,
policy, preflight, execution, verification, then outcome. Failed policy,
preflight, or execution phases may move directly to outcome; successful phases
cannot skip their next check.

The scope digest binds the canonical project root, root-binding revision,
project update revision, bounded policy version, normalized capability set, and
the repository-read ceiling. A replay with the same idempotency key fails closed
if any of those inputs changed. Reordering or repeating the same capabilities
does not create a false change.

Every adapter receives an `AbortSignal` and deadline. A timeout aborts the
signal, and Forge waits for the fixed adapter and its audit work to settle before
recording a terminal timeout. An injected adapter that ignores cancellation is
left as an incomplete recovery-required run rather than being terminalized
while work may still continue.

A replay that finds a nonterminal `running` row also fails explicitly as
recovery-required. The incomplete row remains audit evidence; after inspection,
an operator or recovery workflow uses a new attempt key. V1 does not guess that
a stale read completed and does not mutate incomplete history.

## Adding or changing an operation

Add a versioned definition to `web/lib/operations/catalog.ts`, add only a fixed
adapter kind to the closed TypeScript union and executor switch, and add tests
for exact inputs, policy denial, timeout, output verification, and idempotency.
A new path, command, permission, risk, scope, executor, or verification rule is
a new version. Reviewers must confirm that all dynamic values are validated and
that existing repository, MCP, security, and human-approval ceilings remain
stricter. Project-local and model-created registrations are not supported in v1.

To retire an operation, add its replacement first, mark the old definition
deprecated, and keep its historical version readable. Do not edit historical
ledger rows or reuse a version number.

## Auditing

Operators audit `operation_runs` for identity, fingerprints, policy, and the
canonical outcome link, then read `operation_run_events` in sequence order.
Evidence references are UUIDs that point to existing Forge evidence records;
technical output stays in those bounded records. A missing outcome, incomplete
phase history, digest mismatch, or invalid evidence reference is unavailable or
failed evidence, never implied success.

## Consequences

This foundation deliberately provides narrow read automation, not general
command authority. Write operations, rollback actions, independent workforce
verification, project-local definitions, and earned-autonomy promotion require
later reviewed versions and integrations.

This PR establishes the production-safe composition but does not yet connect a
normal agent/model task path to `executeTrustedOperation`. That integration is
a required follow-on before issue 201 can be considered fully closed. MCP health
is also deferred until its dependency chain supports real cancellation.
4 changes: 2 additions & 2 deletions scripts/ci/prove-installer-managed-migrations.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ assert_latest_and_clean() {
PGPASSWORD="$FORGE_INSTALLER_MANAGED_ADMIN_PASSWORD" PGHOST="$FORGE_INSTALLER_MANAGED_ADMIN_HOST" PGUSER="$FORGE_INSTALLER_MANAGED_ADMIN_USER" PGDATABASE="$database_name" psql --set ON_ERROR_STOP=1 <<'SQL'
DO $proof$
BEGIN
IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 30
OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1785820800000 THEN
IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 31
OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1785993600000 THEN
RAISE EXCEPTION 'Managed installer did not apply the exact latest migration ledger';
END IF;
IF pg_catalog.to_regclass('public.forge_epic_172_s3_release_state') IS NULL THEN
Expand Down
5 changes: 3 additions & 2 deletions web/__tests__/local-projection-overlimit-archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,10 +378,11 @@ describe('local-projection over-limit operator commands', () => {
'utf8',
)
for (const evidence of [
'count(*) FROM drizzle.__drizzle_migrations) <> 30',
'count(DISTINCT created_at) FROM drizzle.__drizzle_migrations) <> 30',
'count(*) FROM drizzle.__drizzle_migrations) <> 31',
'count(DISTINCT created_at) FROM drizzle.__drizzle_migrations) <> 31',
'created_at = 1784270400000',
'created_at = 1784274000000',
'max(created_at) FROM drizzle.__drizzle_migrations) <> 1785993600000',
"role.rolname = 'forge_local_projection_archiver'",
'role.rolpassword IS NULL',
'pg_catalog.pg_db_role_setting',
Expand Down
43 changes: 43 additions & 0 deletions web/__tests__/operation-adapters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'

import {
createRepositoryBranchReadAdapter,
createRepositoryDiffSummaryAdapter,
createRepositoryStatusReadAdapter,
} from '@/worker/operations/adapters/repository-read'

describe('fixed repository operation adapters', () => {
it('constructs fixed argv without a shell or model-controlled path and preserves the trusted root', async () => {
const runCommand = vi.fn(async () => ({ exitCode: 0, outputSummary: 'bounded output' }))
const status = createRepositoryStatusReadAdapter({ runCommand })
const diff = createRepositoryDiffSummaryAdapter({ runCommand })
const branch = createRepositoryBranchReadAdapter({ runCommand })

await expect(status({ projectRoot: '/trusted/project' })).resolves.toMatchObject({
output: { exitCode: 0, summary: 'bounded output' },
})
await expect(diff({ projectRoot: '/trusted/project' })).resolves.toMatchObject({
output: { exitCode: 0, summary: 'bounded output' },
})
await expect(branch({ projectRoot: '/trusted/project' })).resolves.toMatchObject({
output: { exitCode: 0, summary: 'bounded output' },
})

expect(runCommand.mock.calls).toEqual([
[{ cwd: '/trusted/project', command: 'git', argv: ['status', '--short'] }],
[{ cwd: '/trusted/project', command: 'git', argv: ['diff', '--no-ext-diff', '--no-textconv', '--stat', '--'] }],
[{ cwd: '/trusted/project', command: 'git', argv: ['branch', '--show-current'] }],
])
})

it.each(['relative/project', '../outside', 'trusted/project\0outside'])(
'rejects an unsafe trusted root before invoking the command boundary: %s',
async (projectRoot) => {
const runCommand = vi.fn(async () => ({ exitCode: 0, outputSummary: '' }))
const status = createRepositoryStatusReadAdapter({ runCommand })

await expect(status({ projectRoot })).rejects.toThrow('absolute filesystem path')
expect(runCommand).not.toHaveBeenCalled()
},
)
})
42 changes: 42 additions & 0 deletions web/__tests__/operation-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'

import {
BUILT_IN_OPERATIONS,
createOperationCatalog,
resolveOperationDefinition,
validateOperationInputs,
} from '@/lib/operations/catalog'
import { OperationContractError, parseOperationRequest } from '@/lib/operations/contracts'

function request(overrides: Record<string, unknown> = {}) {
return {
schemaVersion: 1,
operationId: 'repository.status.read',
operationVersion: 1,
inputs: {},
reason: 'Inspect repository state.',
requestedCapability: 'filesystem.project.read',
...overrides,
}
}

describe('operation catalog v1', () => {
it('requires schemaVersion 1 and exact request keys', () => {
expect(() => parseOperationRequest(request({ schemaVersion: 2 }))).toThrow(OperationContractError)
expect(() => parseOperationRequest(request({ cwd: '/tmp/escape' }))).toThrow('exactly the v1 request keys')
})

it('preserves an exact empty input object and rejects command or path injection', () => {
const parsed = parseOperationRequest(request())
expect(parsed.inputs).toEqual({})
const definition = resolveOperationDefinition(parsed)
expect(() => validateOperationInputs(definition, parsed.inputs)).not.toThrow()
expect(() => validateOperationInputs(definition, { argv: ['sh', '-c', 'rm -rf /'] })).toThrow(OperationContractError)
expect(() => validateOperationInputs(definition, { projectPath: '../../outside' })).toThrow(OperationContractError)
})

it('rejects duplicate ids and unsupported versions', () => {
expect(() => createOperationCatalog([BUILT_IN_OPERATIONS[0], BUILT_IN_OPERATIONS[0]])).toThrow('Duplicate')
expect(() => resolveOperationDefinition({ operationId: 'repository.status.read', operationVersion: 2 })).toThrow('version')
})
})
Loading