## Summary
Chat-driven `fn_task_create` rejected freeform intake with `Approved
mission_lineage is required` even though the tool schema marks
`mission_lineage` as optional. That was FN-8307 mission admission
over-applied beyond autonomous heartbeat patrol.
This restores freeform chat/board-equivalent creates while keeping
idle-heartbeat mission-lineage enforcement.
## What changed
- **`fn_task_create` / `fn_delegate_task`**: omit `mission_lineage`
succeeds for user-directed surfaces; hard-require only when the tool is
registered with `requireMissionLineage` (idle heartbeat patrol).
- **Gates**: missing lineage is policy-governed (`allow` /
`require-approval` / `block`) instead of a hard pre-block, so
permanent-agent chat can create freeform tasks under normal policy.
- **Heartbeat no-task delegate**: also sets `requireMissionLineage:
true` so freeform off-mission work cannot bypass admission via
`fn_delegate_task`.
- Supplied lineage is still fully validated (Feature → Slice → Milestone
→ Mission) on every surface.
- Parent inheritance still applies when not in require mode.
## Test plan
- [x] Unit: freeform `fn_task_create` without lineage creates a task
with no `missionId`/`sliceId`
- [x] Unit: freeform `fn_delegate_task` without lineage succeeds
- [x] Unit: `requireMissionLineage: true` still hard-fails without
lineage
- [x] Unit: gates treat missing lineage as policy disposition, not hard
block
- [ ] CI gate green
## Symptom
**Original:** chat tool call `{ description: "Create a red button",
priority: "high" }` → `ERROR: Approved mission_lineage is required; no
task was created.`
**Expected after fix:** task is created freeform without mission fields.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **Bug Fixes**
- Freeform chat task creation and delegation can now proceed without
`mission_lineage`.
- Permission policies continue to govern these actions, including
approval requirements.
- Autonomous idle patrols still require approved mission lineage before
creating or delegating tasks.
- Task creation no longer receives mission-specific metadata when no
lineage is provided.
- **Tests**
- Expanded coverage for freeform and mission-linked task creation,
delegation, and policy-gating scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
196 lines
7.0 KiB
TypeScript
196 lines
7.0 KiB
TypeScript
import type {
|
|
AgentPermissionPolicyDisposition,
|
|
PermanentAgentActionCategory,
|
|
PermanentAgentGatingContext,
|
|
PermanentAgentSensitiveActionCategory,
|
|
} from "@fusion/core";
|
|
import {
|
|
COMMAND_EXECUTION_FN_TOOLS,
|
|
FILE_SCOPE_FN_TOOLS,
|
|
FILE_WRITE_BUILTIN_TOOLS,
|
|
FILE_WRITE_DELETE_FN_TOOLS,
|
|
NETWORK_API_TOOLS,
|
|
PERMANENT_AGENT_TASK_MUTATION_TOOLS,
|
|
READONLY_BUILTIN_TOOLS,
|
|
READONLY_FN_TOOLS,
|
|
REVIEW_GATE_BYPASS_FN_TOOLS,
|
|
isGitWriteCommand,
|
|
} from "./gating-classifications.js";
|
|
|
|
export interface PermanentAgentToolClassification {
|
|
category: PermanentAgentActionCategory;
|
|
/** True only when the tool is positively recognized and mapped by this module. */
|
|
recognized: boolean;
|
|
}
|
|
|
|
export interface PermanentAgentToolDecision extends PermanentAgentToolClassification {
|
|
toolName: string;
|
|
disposition: AgentPermissionPolicyDisposition;
|
|
}
|
|
|
|
const FILE_WRITE_TOOLS = FILE_WRITE_BUILTIN_TOOLS;
|
|
|
|
// FN-3724 / FN-3548: heartbeat-completion and internal coordination tools must remain
|
|
// category "none" so restrictive permanent-agent policies cannot deadlock heartbeats.
|
|
const TASK_AGENT_MUTATION_TOOLS = PERMANENT_AGENT_TASK_MUTATION_TOOLS;
|
|
const FILE_WRITE_DELETE_TOOLS = FILE_WRITE_DELETE_FN_TOOLS;
|
|
const COMMAND_EXECUTION_TOOLS = COMMAND_EXECUTION_FN_TOOLS;
|
|
// FNXC:ToolGovernance 2026-07-09-00:00: FN-7728 — mirror agent-action-gate.ts's review_gate_bypass classification here so the permanent-agent gate resolves fn_task_bypass_review identically (no two-path drift).
|
|
const REVIEW_GATE_BYPASS_TOOLS = REVIEW_GATE_BYPASS_FN_TOOLS;
|
|
// FNXC:ToolGovernance 2026-07-09-08:30: FN-7737 — mirror agent-action-gate.ts's file_scope classification here so the permanent-agent gate resolves fn_task_file_scope_add identically (no two-path drift).
|
|
const FILE_SCOPE_TOOLS = FILE_SCOPE_FN_TOOLS;
|
|
|
|
function normalizeArgs(args: unknown): Record<string, unknown> {
|
|
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
|
}
|
|
|
|
function extractShellCommand(args: Record<string, unknown>): string {
|
|
const command = args.command;
|
|
return typeof command === "string" ? command.trim() : "";
|
|
}
|
|
|
|
const GATED_SUMMARY_COMMAND_MAX_LENGTH = 200;
|
|
|
|
function truncateForSummary(value: string, maxLength: number): string {
|
|
const singleLine = value.replace(/\s+/g, " ").trim();
|
|
if (singleLine.length <= maxLength) {
|
|
return singleLine;
|
|
}
|
|
return `${singleLine.slice(0, maxLength - 1)}\u2026`;
|
|
}
|
|
|
|
function renderCompactArgs(args: Record<string, unknown>): string {
|
|
const entries = Object.entries(args).filter(([, value]) => value !== undefined);
|
|
if (entries.length === 0) {
|
|
return "";
|
|
}
|
|
const rendered = entries
|
|
.slice(0, 4)
|
|
.map(([key, value]) => {
|
|
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
return `${key}: ${truncateForSummary(String(stringValue ?? ""), 60)}`;
|
|
})
|
|
.join(", ");
|
|
const suffix = entries.length > 4 ? ", \u2026" : "";
|
|
return `{${rendered}${suffix}}`;
|
|
}
|
|
|
|
/**
|
|
* FNXC:AgentGating 2026-07-05-00:00:
|
|
* FN-7609: operators approving a gated agent action need to see the real
|
|
* payload (shell command line, or tool arguments), not just a generic
|
|
* "Agent gated action for <tool>" placeholder. This pure helper builds a
|
|
* payload-bearing, human-readable summary shared by both permanent-agent
|
|
* gating context builders (executor.ts and agent-heartbeat.ts) so approval
|
|
* cards are actionable instead of blank.
|
|
*/
|
|
export function buildAgentGatedActionSummary(toolName: string, args: unknown): string {
|
|
const normalizedArgs = normalizeArgs(args);
|
|
|
|
if (toolName === "bash") {
|
|
const command = extractShellCommand(normalizedArgs);
|
|
if (command) {
|
|
return `Run: ${truncateForSummary(command, GATED_SUMMARY_COMMAND_MAX_LENGTH)}`;
|
|
}
|
|
}
|
|
|
|
const compactArgs = renderCompactArgs(normalizedArgs);
|
|
if (compactArgs) {
|
|
return `${toolName} ${compactArgs}`;
|
|
}
|
|
|
|
return `Agent gated action for ${toolName}`;
|
|
}
|
|
|
|
|
|
export function classifyPermanentAgentToolCall(
|
|
toolName: string,
|
|
args?: unknown,
|
|
): PermanentAgentToolClassification {
|
|
if (FILE_WRITE_TOOLS.has(toolName)) {
|
|
return { category: "file_write_delete", recognized: true };
|
|
}
|
|
if (toolName === "bash") {
|
|
const command = extractShellCommand(normalizeArgs(args));
|
|
return { category: isGitWriteCommand(command) ? "git_write" : "command_execution", recognized: true };
|
|
}
|
|
if (READONLY_BUILTIN_TOOLS.has(toolName)) {
|
|
return { category: "none", recognized: true };
|
|
}
|
|
if (REVIEW_GATE_BYPASS_TOOLS.has(toolName)) {
|
|
return { category: "review_gate_bypass", recognized: true };
|
|
}
|
|
if (FILE_SCOPE_TOOLS.has(toolName)) {
|
|
return { category: "file_scope", recognized: true };
|
|
}
|
|
if (TASK_AGENT_MUTATION_TOOLS.has(toolName)) {
|
|
return { category: "task_agent_mutation", recognized: true };
|
|
}
|
|
if (FILE_WRITE_DELETE_TOOLS.has(toolName)) {
|
|
return { category: "file_write_delete", recognized: true };
|
|
}
|
|
if (COMMAND_EXECUTION_TOOLS.has(toolName)) {
|
|
return { category: "command_execution", recognized: true };
|
|
}
|
|
if (NETWORK_API_TOOLS.has(toolName)) {
|
|
return { category: "network_api", recognized: true };
|
|
}
|
|
if (READONLY_FN_TOOLS.has(toolName) || /^fn_(?:list|show|get|read|browse)_/.test(toolName)) {
|
|
return { category: "none", recognized: true };
|
|
}
|
|
|
|
return { category: "none", recognized: false };
|
|
}
|
|
|
|
function resolvePolicyDisposition(
|
|
toolName: string,
|
|
category: PermanentAgentSensitiveActionCategory,
|
|
gating: PermanentAgentGatingContext | undefined,
|
|
): AgentPermissionPolicyDisposition {
|
|
/*
|
|
FNXC:ToolPermissions 2026-07-01-00:00:
|
|
Permanent-agent heartbeats use exact tool overrides before category rules so a policy can block `fn_task_create` while leaving sibling task-agent mutations allowed. Unknown tools still fail safe to approval and category `none` coordination tools remain non-configurable.
|
|
*/
|
|
return gating?.permissionPolicy?.toolRules?.[toolName]
|
|
?? gating?.permissionPolicy?.rules?.[category]
|
|
?? "require-approval";
|
|
}
|
|
|
|
export function resolvePermanentAgentToolDecision(input: {
|
|
toolName: string;
|
|
args?: unknown;
|
|
gating?: PermanentAgentGatingContext;
|
|
}): PermanentAgentToolDecision {
|
|
const classification = classifyPermanentAgentToolCall(input.toolName, input.args);
|
|
|
|
/*
|
|
FNXC:MissionAdmission 2026-07-22-13:07:
|
|
Freeform chat creates omit mission_lineage and must honor policy disposition
|
|
(allow/require-approval/block), not a hard gate block. Autonomous heartbeat
|
|
patrol still enforces lineage at the tool factory via requireMissionLineage.
|
|
Keep permanent-agent results in lockstep with evaluateAgentActionGate.
|
|
*/
|
|
|
|
if (!input.gating?.permissionPolicy) {
|
|
return {
|
|
...classification,
|
|
toolName: input.toolName,
|
|
disposition: "allow",
|
|
};
|
|
}
|
|
|
|
if (classification.category === "none") {
|
|
return {
|
|
...classification,
|
|
toolName: input.toolName,
|
|
disposition: classification.recognized ? "allow" : "require-approval",
|
|
};
|
|
}
|
|
|
|
return {
|
|
...classification,
|
|
toolName: input.toolName,
|
|
disposition: resolvePolicyDisposition(input.toolName, classification.category, input.gating),
|
|
};
|
|
}
|