Files
fusion/packages/cli/src/__tests__/extension-gitlab-tracking.test.ts
gsxdsm 189f237a07 fix(security): make the approval and permission gates actually enforce (#2457)
## Why

An AI agent autonomously deleted a live task (FN-8609) via
`fn_task_delete`. Investigating that led to two audits, which found the
gate machinery is well-built and its *enforcement* well-tested — but the
*decision boundary* was unguarded and the tools that matter never
reached the gate at all.

The headline defect: all ~76 tools in `packages/cli/src/extension.ts`
are delivered through `hostExtensionPaths` → `DefaultResourceLoader`,
which never passes through `wrapToolsWithActionGate`. `fn_task_delete`
*was* classified as gated in `gating-classifications.ts` — that
classification was dead code. Roughly 15 destructive tools were ungated
even for an operator who had locked their policy all the way down.

Second: `POST /api/approvals/:id/decision` took the approver identity
from the request body, validated for shape only, with no check that the
decider was human or differed from the requester. Combined with a bash
tool whose command string was never inspected (only `params.cwd`), an
agent could read the daemon token from `~/.fusion/settings.json`, find
its own pending request, and approve it.

## Scope

This does **not** tighten defaults. The `unrestricted` preset is
byte-for-byte unchanged — `git diff` on `agent-permission-policy.ts` is
empty — and regression tests assert that an out-of-the-box install
behaves exactly as before. The bug was never "the default is too
permissive"; it was "strict policy doesn't enforce." This makes turning
security up actually work.

The one deliberate exception: the containment that stops an agent
escalating its *own* privileges (reading the daemon token / credentials,
calling the approvals API to self-approve) applies at every preset
including `unrestricted`. That is a privilege-escalation boundary rather
than a permission preference — if it only engaged under strict policy it
would not have prevented the incident that prompted this.

## What changed

8 bisectable commits:

- **Approval lifecycle** — self-approval blocked via server-derived
deciders; same-verdict replay 409s; decide re-reads and re-validates
inside the transaction; expiry TTLs; `markCompleted` ownership check;
session identity registry in core.
- **Engine gates enforce for real** — unclassified tools resolve to a
policy-governed category instead of hardcoded `allow`; missing-policy
fail-open closed; bash containment floor + exact-command approval
binding.
- **Dashboard decision routes** — stop trusting client-supplied actors
(decision, bypass-review, worktrunk → 403 on forged actors).
- **`fn serve` authenticated by default** — auto-mints a token following
the existing `fn dashboard` precedent; `--no-auth` opts out.
- **Sibling entry points closed** — user-sourced hard-cancel moves, ACP
execute-once approvals, plugin task-store gating.
- **pi-extension principal resolution** — the extension resolves the
acting principal and can withhold or policy-gate the previously ungated
destructive tools.
- **Root-cause bonus fix** — `findLatestByDedupeKey` was broken in
PostgreSQL backend mode (already-parsed jsonb fed through a string-only
parser), so approved-grant redemption **never matched in production**,
minting duplicate requests. This explains the live DB state of 17
approved / 0 completed. *(Also cherry-picked to `main` as `a9b30013bb`,
since it is an active production defect on its own.)*
- **Review follow-ups** (`627f1b1fa8`) — operator-configured
provisioning privilege and a configurable grant TTL; see below.

## Review follow-ups

**Provisioning privilege is operator-configured, not role-derived.**
`isCallerPrivileged` had gone from `caller.reportsTo == null` (every
top-level agent privileged — permanent escalation by creating a
manager-less agent) to `caller.role === "ceo"`, which swapped an
implicit rule for a magic string: any agent config can claim that role,
while an operator who genuinely wants a privileged agent had no
supported way to say so. Privilege now derives solely from
`agentProvisioning.trustedAgentIds` / `trustedRoles` and fails closed
when settings are unresolvable.

It is also no longer forwarded to `resolveAgentProvisioningPolicy` as
`isPrivileged`, because that flag short-circuits ahead of
`alwaysApproveDelete` — a trusted caller was bypassing delete approval
entirely. The policy applies the same trusted rules itself, in the right
order. The function now governs only the org-chart escape hatch (acting
outside your own direct reports).

**Grant TTL defaults to 1 hour and is configurable.** Approval →
redemption is not instantaneous: an operator approving from their phone,
an engine restart, a queued lane, or a task waiting on a worktree all
routinely exceeded 15 minutes, after which the grant expired and the
agent silently re-requested. One hour remains far short of the
"redeemable forever" hazard the TTL exists to bound. Override via
`FUSION_APPROVAL_GRANT_TTL_MS` or `configureApprovalRequestTtls()`;
invalid overrides are ignored rather than widening the window to
infinity or collapsing it to zero.

## Behavior changes requiring operator review before rollout

1. `fn serve` requires a bearer token by default (`--no-auth` opts out);
unauthenticated clients get 401.
2. Agents can no longer run withheld destructive tools
(`fn_task_delete`, `fn_task_bypass_review`,
mission/milestone/slice/feature/workflow deletes, `experiment_finalize`,
`skills_install`). Operators keep them via CLI/dashboard. **This is the
incident fix.**
3. Agents get provisioning privilege only when the operator lists them
in `agentProvisioning.trustedAgentIds` / `trustedRoles`; the
provisioning gate is now live in production. Previously-implicit
privilege (top-level position, or a `ceo` role) no longer grants
anything on its own.
4. Decision replay 409s (was 200); pending approvals expire after 24h,
approved grants after 1h (configurable); bash approvals bind per exact
command.
5. Forged/body actors on decision, bypass-review, worktrunk routes →
403; `archive-all-done` requires `{confirm:true}` (external scripts
affected).
6. `fn_secret_get` approvals grant exactly one reveal (previously
granted nothing and looped forever); ACP approvals are execute-once
(previously infinite reuse).
7. Bash containment denies token/credential/approvals-API commands in
all agent sessions at every preset.

## Verification

Independently re-run against the branch, not just self-reported:

- 5 typechecks (core, engine, cli, dashboard `tsconfig.json` +
`tsconfig.app.json`) — clean
- `pnpm lint` — clean
- `pnpm test:gate` — 379 passed
- `pnpm build --force` — green (a plain `pnpm build` skips packages as
unchanged and does **not** compile the branch)
- `pnpm check:changesets` — clean
- ~650 file-scoped tests including new negative-path suites for the
decision boundary, which previously had **zero** test coverage

`packages/engine/src/__tests__/plugin-runner.test.ts` fails 56/80 —
**verified pre-existing**, reproducing identically at base commit
`93a403af67` on `main`. Not in the merge gate.

### A mutation check that failed to fail

Worth recording, because it nearly shipped an untested security fix. The
first mutation check on the provisioning change reintroduced the `ceo`
hardcode and **all 17 tests still passed** — the tests asserted through
the policy path, which can no longer observe `isCallerPrivileged` at
all, precisely because `isPrivileged` is no longer forwarded there.
Org-chart cases that do exercise the function were added; the hardcode
now fails exactly 1 of 19, and restoring is green. A green mutation run
is only meaningful if the test can actually see the code under test.

## Known limitations (stated, not papered over)

- The bash containment floor is string-matching: a cost-raiser, not a
sandbox. Quoting, encoding, `$HOME`, symlinks, or an interpreter
one-liner can evade it. The durable protection is the decision route
refusing agent-originated deciders — the filter is the belt, not the
braces.
- Approval expiry is lazy (evaluated at decide/complete/redeem), not
swept, so an expired pending row stays visible in lists until touched.
- The extension's require-approval path returns a pending message but
cannot suspend a pi session mid-turn; engine-side pause hooks cover
engine lanes only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Security**
* Hardened approval and permission gating with server-side decider
attribution, self-approval blocking, ownership checks, replay/race
protection, and status/TTL enforcement.
* Added fail-closed behavior for sensitive/unclassified tools and
sandbox provisioning approvals.
* Blocked credential/approval access via bash containment; plugin
destructive task operations now require explicit permission.
* **New Features**
* `fn serve` now defaults to bearer-token auth, with `--no-auth` as the
explicit opt-out.
* **Bug Fixes**
* Improved task move-source attribution (`moveSource: "user"`) and
tightened dashboard archive/bypass confirmation and operator attribution
behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:50:37 -07:00

165 lines
8.5 KiB
TypeScript

import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
import {
createMockApi,
createPgExtensionHarness,
pgDescribe,
} from "./pg-extension-harness.js";
const gitlabIssues = vi.hoisted(() => ({
project: [{ resourceKind: "project_issue", id: 1, iid: 2, projectId: 3, projectPath: "g/p", title: "Project issue", description: "Body", webUrl: "https://gitlab.example.com/g/p/-/issues/2", state: "opened", labels: [] }],
group: [{ resourceKind: "group_issue", id: 4, iid: 7, projectId: 8, projectPath: "g/p", groupPath: "g", title: "Group issue", description: null, webUrl: "https://gitlab.example.com/g/p/-/issues/7", state: "opened", labels: [] }],
mrs: [{ resourceKind: "merge_request", id: 5, iid: 9, projectId: 8, projectPath: "g/p", title: "Merge request", description: "MR", webUrl: "https://gitlab.example.com/g/p/-/merge_requests/9", state: "opened", labels: [], sourceBranch: "feat", targetBranch: "main" }],
}));
vi.mock("@fusion/dashboard", () => {
class GitLabClient {
auth: any;
constructor(auth: any) { this.auth = auth; }
listProjectIssues = vi.fn(async () => gitlabIssues.project);
listGroupIssues = vi.fn(async () => gitlabIssues.group);
listMergeRequests = vi.fn(async () => gitlabIssues.mrs);
}
return {
registerGithubTrackingHook: vi.fn(),
resolveGitlabAuth: vi.fn(({ projectSettings }: any) => projectSettings.gitlabAuthToken
? { ok: true, auth: { apiBaseUrl: "https://gitlab.example.com/api/v4", webBaseUrl: "https://gitlab.example.com", token: projectSettings.gitlabAuthToken, tokenType: "personal", headerName: "PRIVATE-TOKEN" } }
: { ok: false, message: "GitLab auth requires a configured access token" }),
GitLabClient,
buildGitLabTaskDescription: (item: any) => `${item.description?.trim() || "(no description)"}\n\nSource: ${item.webUrl}`,
buildGitLabTaskProvenance: ({ resourceType, item, groupInput }: any) => ({
sourceIssue: { provider: "gitlab", repository: item.projectPath ?? String(item.projectId), externalIssueId: String(item.id), issueNumber: item.iid, url: item.webUrl },
gitlabTracking: { item: { kind: resourceType, iid: item.iid, id: item.id, projectId: item.projectId, projectPath: item.projectPath, groupPath: item.groupPath ?? groupInput, url: item.webUrl, host: "gitlab.example.com", instanceUrl: "https://gitlab.example.com", title: item.title, state: item.state } },
sourceMetadata: { provider: "gitlab", resourceType, iid: item.iid, groupInput, projectPath: item.projectPath, mergeRequestIid: resourceType === "merge_request" ? item.iid : undefined },
}),
isGitLabAlreadyImported: (task: any, provenance: any) => task.sourceIssue?.url === provenance.sourceIssue.url,
};
});
vi.mock("@fusion/engine", () => ({
installBaselineArchiveWorktreeDisposer: vi.fn(),
// FNXC:ToolPermissionGates 2026-07-26-14:55: extension.ts now imports the agent action gate; mock completeness gate requires these names.
evaluateAgentActionGate: vi.fn(() => ({ disposition: "allow", category: "exempt", toolName: "", operation: "", summary: "", resourceType: "other", approvalDedupeKey: "", metadata: {} })),
resolveGateOutcome: vi.fn(() => ({ outcome: "allow" })),
createFnAgent: vi.fn(),
createAgentTask: vi.fn(),
fetchWebContent: vi.fn(),
assertNoSecretPlaintext: vi.fn(),
emitGoalRetrievalAudit: vi.fn(),
createWorkflowAuthoringTools: vi.fn(() => ({})),
// FNXC:TestInfrastructure 2026-07-13-10:25: Complete the engine mock for extension.ts named imports (experiment finalize, workflow params, etc.).
defaultGitOps: {},
ExperimentFinalizeBranchExistsError: class MockError extends Error {},
ExperimentFinalizeCherryPickConflictError: class MockError extends Error {},
ExperimentFinalizeMergeBaseError: class MockError extends Error {},
ExperimentFinalizeNoKeptRunsError: class MockError extends Error {},
ExperimentFinalizePlanError: class MockError extends Error {},
ExperimentFinalizeService: vi.fn(),
ExperimentFinalizeStateError: class MockError extends Error {},
isInReviewMissingWorktreeSessionStartFailure: vi.fn(),
workflowListParams: {},
workflowGetParams: {},
workflowSelectParams: {},
workflowCreateParams: {},
workflowUpdateParams: {},
workflowDeleteParams: {},
workflowValidateParams: {},
workflowSettingsParams: {},
traitListParams: {},
normalizeAgentLogPaging: vi.fn(() => ({ limit: 100, offset: 0 })),
renderAgentLogEntries: vi.fn(() => ""),
workflowListParams: {},
workflowGetParams: {},
workflowValidateParams: {}, // FNXC:Round10 FN-7911 added this export to @fusion/engine barrel
workflowSelectParams: {},
workflowCreateParams: {},
workflowUpdateParams: {},
workflowDeleteParams: {},
workflowSettingsParams: {},
traitListParams: {},
}));
async function loadExtension() {
const mod = await import("../extension.js");
return mod.default;
}
const h = createPgExtensionHarness("fn-8094-gitlab");
async function setupTools() {
await h.store().updateSettings({ gitlabAuthToken: "glpat_test", gitlabInstanceUrl: "https://gitlab.example.com" });
const extension = await loadExtension();
const api = createMockApi();
extension({
...api,
registerShortcut: vi.fn(),
registerFlag: vi.fn(),
} as any);
return { cwd: h.rootDir(), tools: api.tools };
}
/*
FNXC:PostgresCutover 2026-07-16-05:40:
This GitLab extension suite runs on the shared PostgreSQL harness instead of the
removed inMemoryDb runtime. Its preserved tracking assertions require FN-8094
rowToTask hydration so imported GitLab provenance survives ordinary store reads.
FNXC:GitLabExtension 2026-07-02-00:00:
GitLab extension tools are HTTP API tools backed by configured GitLab token settings. They must expose project/group/MR schemas, create local GitLab source/tracking metadata, and never depend on a `glab` binary or real network.
*/
pgDescribe("extension GitLab import tools", () => {
beforeAll(h.beforeAll);
beforeEach(async () => {
await h.beforeEach();
vi.clearAllMocks();
});
afterEach(async () => {
vi.restoreAllMocks();
await h.afterEach();
});
afterAll(h.afterAll);
it("registers browse/import tools for project issues, group issues, and merge requests", async () => {
const { tools } = await setupTools();
for (const name of [
"fn_task_browse_gitlab_project_issues",
"fn_task_import_gitlab_project_issues",
"fn_task_browse_gitlab_group_issues",
"fn_task_import_gitlab_group_issues",
"fn_task_browse_gitlab_merge_requests",
"fn_task_import_gitlab_merge_requests",
]) {
expect(tools.get(name), name).toBeTruthy();
expect(JSON.stringify(tools.get(name).parameters)).toContain(name.includes("group") ? "group" : "project");
expect(tools.get(name).description).toMatch(/GitLab/);
}
});
it("imports GitLab project, group, and MR resources with source and tracking metadata", async () => {
const { cwd, tools } = await setupTools();
const project = await tools.get("fn_task_import_gitlab_project_issues").execute("p", { project: "g/p", limit: 1 }, undefined, undefined, { cwd });
const group = await tools.get("fn_task_import_gitlab_group_issues").execute("g", { group: "g", limit: 1 }, undefined, undefined, { cwd });
const mr = await tools.get("fn_task_import_gitlab_merge_requests").execute("m", { project: "g/p", limit: 1 }, undefined, undefined, { cwd });
expect(project.content[0].text).toContain("Imported 1 GitLab project issue tasks");
expect(group.content[0].text).toContain("Imported 1 GitLab group issue tasks");
expect(mr.content[0].text).toContain("Imported 1 GitLab merge request tasks");
const tasks = await h.store().listTasks({ slim: false });
expect(tasks.map((task) => task.sourceIssue?.provider)).toEqual(["gitlab", "gitlab", "gitlab"]);
expect(tasks.map((task) => task.gitlabTracking?.item?.kind)).toEqual(["project_issue", "group_issue", "merge_request"]);
expect(tasks.find((task) => task.gitlabTracking?.item?.kind === "group_issue")?.gitlabTracking?.item?.groupPath).toBe("g");
expect(tasks.find((task) => task.gitlabTracking?.item?.kind === "merge_request")?.title).toMatch(/^Review MR !9:/);
});
it("returns human-readable auth errors without leaking token-like input", async () => {
const { cwd, tools } = await setupTools();
await h.store().updateSettings({ gitlabAuthToken: null as any });
await expect(tools.get("fn_task_browse_gitlab_project_issues").execute("p", { project: "g/p" }, undefined, undefined, { cwd }))
.resolves.toMatchObject({
isError: true,
details: { error: "GitLab auth requires a configured access token" },
});
});
});