Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ In `src/registry/index.ts`:
- `"org"` — adds `accountIdentifier`, `orgIdentifier`
- `"account"` — adds `accountIdentifier` only

Use `supportedScopes` when one Harness resource type can operate at multiple levels. For example, connectors, services, environments, infrastructure, secrets, and templates support `["account", "org", "project"]`. Tool callers select the level with `resource_scope`, and `harness_describe` surfaces both `supportedScopes` and a `scopeHint`. If `resource_scope` is omitted, the registry uses the resource's default `scope`; `scopeOptional: true` resources may omit org/project unless explicitly passed.
Use `supportedScopes` when one Harness resource type can operate at multiple levels. For example, connectors, services, environments, infrastructure, secrets, templates, policies, and policy sets support `["account", "org", "project"]`. Tool callers select the level with `resource_scope`, and `harness_describe` surfaces both `supportedScopes` and a `scopeHint`. If `resource_scope` is omitted, the registry uses the resource's default `scope`; `scopeOptional: true` resources may omit org/project unless explicitly passed.

**pathParams** maps tool input field names to URL placeholders. For `path: "/api/things/{thingId}"` and `pathParams: { resource_id: "thingId" }`, the user's `resource_id` value replaces `{thingId}`.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,7 @@ The server exposes 11 MCP tools. Most API tools accept `org_id` and `project_id`
- `resource_scope: "org"` sends `accountIdentifier` and `orgIdentifier`.
- `resource_scope: "project"` sends account, org, and project identifiers.

Current multi-scope resources include `connector`, `service`, `environment`, `infrastructure`, `secret`, `file_store`, and `template`. If `resource_scope` is omitted, the registry uses the resource's default scope and configured defaults, except resources marked as optional scope may omit org/project unless explicitly passed. Harness URLs can also set the scope automatically when the path contains account-level or project-level context.
Current multi-scope resources include `connector`, `service`, `environment`, `infrastructure`, `secret`, `file_store`, `template`, `policy`, and `policy_set`. If `resource_scope` is omitted, the registry uses the resource's default scope and configured defaults, except resources marked as optional scope may omit org/project unless explicitly passed. Harness URLs can also set the scope automatically when the path contains account-level or project-level context.

**Structured output:** Every tool declares an MCP `outputSchema`. `harness_list` normalizes list-like Harness responses into object-shaped structured content so strict clients can validate it: top-level arrays become `{ "items": [...], "total": <count>, "page": <page> }`, and common wrapper keys such as `content`, `data`, `body`, `objects`, or `features` are hoisted to `items` when needed. The text response still contains the compact JSON payload returned to all clients.

Expand Down
2 changes: 1 addition & 1 deletion docs/gemini.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Write operations (`harness_create`, `harness_update`, `harness_delete`, `harness

Secret values are never exposed — only metadata (name, type, scope).

Multi-scope resources such as connectors, services, environments, infrastructure, secrets, and templates can be called with `resource_scope: "account" | "org" | "project"`. Use `harness_describe(resource_type="...")` to see `supportedScopes`.
Multi-scope resources such as connectors, services, environments, infrastructure, secrets, templates, policies, and policy sets can be called with `resource_scope: "account" | "org" | "project"`. Use `harness_describe(resource_type="...")` to see `supportedScopes`.

## Setup

Expand Down
3 changes: 3 additions & 0 deletions docs/testing/policy/test_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
| TC-pol-014 | Update | Update rego source | `harness_update(resource_type="policy", policy_id="test_policy", body={rego: "package harness\ndefault allow = false"})` | Rego source updated |
| TC-pol-015 | Delete | Delete policy | `harness_delete(resource_type="policy", policy_id="test_policy")` | Policy deleted |
| TC-pol-016 | Scope | Custom org and project | `harness_list(resource_type="policy", org_id="custom_org", project_id="custom_project")` | Returns policies for specified scope |
| TC-pol-016a | Scope | Account-level list | `harness_list(resource_type="policy", resource_scope="account")` | Returns account-level policies (no org/project query params) |
| TC-pol-016b | Scope | Org-level list | `harness_list(resource_type="policy", resource_scope="org", org_id="custom_org")` | Returns org-level policies (org only, no project) |
| TC-pol-017 | Error | Get nonexistent policy | `harness_get(resource_type="policy", policy_id="nonexistent")` | Returns not found error |
| TC-pol-018 | Error | Create without rego | `harness_create(resource_type="policy", body={identifier: "bad_policy", name: "Bad"})` | Returns validation error (rego required) |
| TC-pol-019 | Error | Create duplicate identifier | `harness_create(resource_type="policy", body={identifier: "<existing>", name: "Dup", rego: "package x"})` | Returns conflict error |
Expand All @@ -41,6 +43,7 @@
## Notes
- Uses v1 API: `/pm/api/v1/policies`
- List uses `per_page` instead of standard `pageSize` (mapped from `size`)
- Supports account/org/project via `resource_scope` (default: project)
- Create body: identifier (required), name (required), rego (required), git_connector_ref (optional), git_path (optional), git_repo (optional)
- Update body (PATCH): name (optional), rego (optional)
- Deep link: `/ng/account/{accountId}/all/orgs/{orgIdentifier}/projects/{projectIdentifier}/settings/governance/policies/edit/{identifier}`
Expand Down
3 changes: 3 additions & 0 deletions docs/testing/policy_set/test_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
| TC-ps-018 | Update | Update policies list | `harness_update(resource_type="policy_set", policy_set_id="test_ps", body={policies: [{identifier: "<policy_id>", severity: "warning"}]})` | Linked policies updated |
| TC-ps-019 | Delete | Delete policy set | `harness_delete(resource_type="policy_set", policy_set_id="test_ps")` | Policy set deleted |
| TC-ps-020 | Scope | Custom org and project | `harness_list(resource_type="policy_set", org_id="custom_org", project_id="custom_project")` | Returns policy sets for specified scope |
| TC-ps-020a | Scope | Account-level list | `harness_list(resource_type="policy_set", resource_scope="account")` | Returns account-level policy sets (no org/project query params) |
| TC-ps-020b | Scope | Org-level list | `harness_list(resource_type="policy_set", resource_scope="org", org_id="custom_org")` | Returns org-level policy sets (org only, no project) |
| TC-ps-021 | Error | Get nonexistent policy set | `harness_get(resource_type="policy_set", policy_set_id="nonexistent")` | Returns not found error |
| TC-ps-022 | Error | Create without required fields | `harness_create(resource_type="policy_set", body={identifier: "bad_ps", name: "Bad"})` | Returns validation error (action, type, enabled required) |
| TC-ps-023 | Edge | Search with no matches | `harness_list(resource_type="policy_set", search_term="zzz_nonexistent_zzz")` | Returns empty list |
Expand All @@ -44,6 +46,7 @@
## Notes
- Uses v1 API: `/pm/api/v1/policysets`
- List uses `per_page` instead of standard `pageSize` (mapped from `size`)
- Supports account/org/project via `resource_scope` (default: project)
- Create body: identifier (required), name (required), action (required), type (required), enabled (required), description (optional), policies (optional array of {identifier, severity}), yaml_version (optional)
- Update body (PATCH): name, action, type, enabled, description, policies, resource_groups, entity_selector — all optional
- action values: onrun, onsave, onpush, etc.
Expand Down
4 changes: 4 additions & 0 deletions src/registry/toolsets/governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const governanceToolset: ToolsetDefinition = {
resourceType: "policy",
displayName: "OPA Policy",
description: "OPA Rego policy for Harness governance. Supports full CRUD. "
+ "Use resource_scope='account'|'org'|'project' to target account-, org-, or project-level policies. Default is project. "
+ "Use this for SCS/SBOM enforcement — create deny-list or allow-list policies that control which components are permitted in your supply chain. "
+ "Policies are written in Rego and evaluated against SBOM components during enforcement. "
+ "To list SBOM/SSCA-enforcement policies specifically, pass filter type='sbom_enforcement' "
Expand All @@ -80,6 +81,7 @@ export const governanceToolset: ToolsetDefinition = {
],
toolset: "governance",
scope: "project",
supportedScopes: ["account", "org", "project"],
identifierFields: ["policy_id"],
deepLinkTemplate: "/ng/account/{accountId}/all/orgs/{orgIdentifier}/projects/{projectIdentifier}/settings/governance/policies/edit/{identifier}",
listFilterFields: [
Expand Down Expand Up @@ -151,6 +153,7 @@ export const governanceToolset: ToolsetDefinition = {
resourceType: "policy_set",
displayName: "OPA Policy Set",
description: "Policy set grouping OPA policies with an enforcement action and an entity type. Supports full CRUD. "
+ "Use resource_scope='account'|'org'|'project' to target account-, org-, or project-level policy sets. Default is project. "
+ "For SCS/SBOM enforcement, use filter type='sbom' to list the policy sets that apply deny-list or allow-list rules during artifact scans — "
+ "the SBOM enforcement step (e.g. action='onstep') binds these sets to the artifact pipeline. "
+ "IMPORTANT TYPE ASYMMETRY: policy sets use type='sbom', while the individual policies they contain use type='sbom_enforcement'. "
Expand All @@ -166,6 +169,7 @@ export const governanceToolset: ToolsetDefinition = {
],
toolset: "governance",
scope: "project",
supportedScopes: ["account", "org", "project"],
identifierFields: ["policy_set_id"],
deepLinkTemplate: "/ng/account/{accountId}/all/orgs/{orgIdentifier}/projects/{projectIdentifier}/settings/governance/policy-sets/{identifier}",
listFilterFields: [
Expand Down
14 changes: 14 additions & 0 deletions tasks/todo.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Harness MCP Server — Task Tracking

## OPA policy multi-scope (this session)
- [x] Add `supportedScopes: ["account", "org", "project"]` + description hints for `policy` and `policy_set`
- [x] Extend governance tests for supportedScopes and `resource_scope` query-param dispatch
- [x] Update README multi-scope list and policy/policy_set test plans; `pnpm build && pnpm docs:generate`
- [x] Run typecheck, governance tests, and standards:check

### Plan
- Mirror connectors/secrets: keep default `scope: "project"`, add `supportedScopes`, do not set `scopeOptional`.
- No endpoint path changes — registry scope injection already handles org/project query params.

### Review
- `policy` / `policy_set` declare `supportedScopes: ["account", "org", "project"]`.
- Governance + registry unit tests: 193 passed; standards:check: 77 passed; build/typecheck/docs:generate OK.

## IaCM workspace create/update (merged #793)
- [x] Add create/update tests and implement `iacm_workspace` writes
- [x] bodySchema + medium_write policy + project-scope preflight
Expand Down
126 changes: 125 additions & 1 deletion tests/registry/governance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
* - policy + policy_set descriptions mention SCS/SBOM enforcement
* - policy + policy_set have relatedResources cross-referencing SCS compliance
* - Structural integrity of governance CRUD operations
* - policy + policy_set support account/org/project via resource_scope
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { governanceToolset } from "../../src/registry/toolsets/governance.js";
import { Registry } from "../../src/registry/index.js";
import type { ResourceDefinition, EndpointSpec } from "../../src/registry/types.js";
import type { Config } from "../../src/config.js";
import type { HarnessClient } from "../../src/client/harness-client.js";

/** Helper: find a resource definition by resourceType */
function findResource(type: string): ResourceDefinition {
Expand All @@ -26,6 +30,33 @@ function getOp(type: string, op: string): EndpointSpec {
return spec;
}

function makeConfig(overrides: Partial<Config> = {}): Config {
return {
HARNESS_API_KEY: "pat.test",
HARNESS_ACCOUNT_ID: "test-account",
HARNESS_BASE_URL: "https://app.harness.io",
HARNESS_ORG: "default",
HARNESS_PROJECT: "test-project",
HARNESS_API_TIMEOUT_MS: 30000,
HARNESS_MAX_RETRIES: 3,
HARNESS_MAX_BODY_SIZE_MB: 10,
HARNESS_RATE_LIMIT_RPS: 10,
HARNESS_READ_ONLY: false,
HARNESS_SKIP_ELICITATION: false,
HARNESS_ALLOW_HTTP: false,
HARNESS_FME_BASE_URL: "https://api.split.io",
LOG_LEVEL: "info",
...overrides,
};
}

function makeClient(requestFn?: (...args: unknown[]) => unknown): HarnessClient {
return {
request: requestFn ?? vi.fn().mockResolvedValue({ data: [] }),
account: "test-account",
} as unknown as HarnessClient;
}

// ─── P3-10: policy resource — SCS/SBOM enforcement enhancements ─────────────

describe("P3-10: policy resource SCS enhancements", () => {
Expand Down Expand Up @@ -373,3 +404,96 @@ describe("SSCA-6347 review: policy_set body schemas document SBOM type", () => {
expect(actionField!.description).toContain("onstep");
});
});

// ─── Multi-scope: account / org / project via resource_scope ────────────────

describe("policy and policy_set multi-scope support", () => {
it.each(["policy", "policy_set"] as const)(
"%s declares supportedScopes account/org/project without scopeOptional",
(resourceType) => {
const res = findResource(resourceType);
expect(res.scope).toBe("project");
expect(res.supportedScopes).toEqual(["account", "org", "project"]);
expect(res.scopeOptional).toBeFalsy();
},
);

it.each(["policy", "policy_set"] as const)(
"%s description documents resource_scope for account/org/project",
(resourceType) => {
const res = findResource(resourceType);
expect(res.description).toContain("resource_scope='account'|'org'|'project'");
expect(res.description).toMatch(/Default is project/i);
},
);

it.each(["policy", "policy_set"] as const)(
"%s list omits org/project for resource_scope=account",
async (resourceType) => {
const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" }));
const mockRequest = vi.fn().mockResolvedValue({ data: [] });
const client = makeClient(mockRequest);

await registry.dispatch(client, resourceType, "list", {
resource_scope: "account",
});

const call = mockRequest.mock.calls[0][0] as { params: Record<string, unknown> };
expect(call.params.orgIdentifier).toBeUndefined();
expect(call.params.projectIdentifier).toBeUndefined();
},
);

it.each(["policy", "policy_set"] as const)(
"%s list injects only org for resource_scope=org",
async (resourceType) => {
const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" }));
const mockRequest = vi.fn().mockResolvedValue({ data: [] });
const client = makeClient(mockRequest);

await registry.dispatch(client, resourceType, "list", {
resource_scope: "org",
org_id: "platform",
});

const call = mockRequest.mock.calls[0][0] as { params: Record<string, unknown> };
expect(call.params.orgIdentifier).toBe("platform");
expect(call.params.projectIdentifier).toBeUndefined();
},
);

it.each(["policy", "policy_set"] as const)(
"%s list keeps project default when resource_scope is omitted",
async (resourceType) => {
const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" }));
const mockRequest = vi.fn().mockResolvedValue({ data: [] });
const client = makeClient(mockRequest);

await registry.dispatch(client, resourceType, "list", {});

const call = mockRequest.mock.calls[0][0] as { params: Record<string, unknown> };
expect(call.params.orgIdentifier).toBe("default");
expect(call.params.projectIdentifier).toBe("test-project");
},
);

it.each(["policy", "policy_set"] as const)(
"%s throws when resource_scope=org lacks org_id and HARNESS_ORG",
async (resourceType) => {
const registry = new Registry(makeConfig({
HARNESS_TOOLSETS: "governance",
HARNESS_ORG: undefined,
HARNESS_PROJECT: undefined,
}));
const mockRequest = vi.fn().mockResolvedValue({ data: [] });
const client = makeClient(mockRequest);

await expect(
registry.dispatch(client, resourceType, "list", {
resource_scope: "org",
}),
).rejects.toThrow(/resource_scope "org" requires org_id or HARNESS_ORG/);
expect(mockRequest).not.toHaveBeenCalled();
},
);
});
2 changes: 2 additions & 0 deletions tests/registry/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,8 @@ describe("Registry", () => {
["infrastructure", "infrastructure"],
["secrets", "secret"],
["templates", "template"],
["governance", "policy"],
["governance", "policy_set"],
])("supports account/org/project list scoping for %s", async (toolset, resourceType) => {
const scopedRegistry = new Registry(makeConfig({ HARNESS_TOOLSETS: toolset }));
const mockRequest = vi.fn().mockResolvedValue({
Expand Down
Loading