diff --git a/.github/workflows/project-native-research.yml b/.github/workflows/project-native-research.yml index 3527cc1a4..d1b8b9151 100644 --- a/.github/workflows/project-native-research.yml +++ b/.github/workflows/project-native-research.yml @@ -4,6 +4,7 @@ on: paths: - '.github/workflows/project-native-research.yml' - 'scripts/project_native_research.py' + - 'scripts/project_native_cursor_acquisition.py' - 'scripts/project_native_service.py' - 'scripts/project_native_collectors/**' - 'scripts/test_project_native_*.py' diff --git a/cli/cmd/agentplugins/cursor_project_cycle_contract_test.go b/cli/cmd/agentplugins/cursor_project_cycle_contract_test.go new file mode 100644 index 000000000..7bf19ae24 --- /dev/null +++ b/cli/cmd/agentplugins/cursor_project_cycle_contract_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/cli/internal/agentpluginscli" +) + +// Real public Cobra/installer/filesystem composition with inert host discovery +// and assessment. No Cursor process or authored MCP process is executed. +func TestProjectCursorDirectMCPCyclePublicContracts(t *testing.T) { + f := newCursorProjectJourney(t, newProjectJourneyTransport(t, "zip")) + projectJourneyWrite(t, filepath.Join(f.a, ".cursor", "mcp.json"), "{\"mcpServers\":{}}\n") + source := projectJourneyTemp(t) + write := func(version string) { + t.Helper() + projectJourneyWrite(t, filepath.Join(source, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"native-proof","version":"`+version+`"}`+"\n") + projectJourneyWrite(t, filepath.Join(source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"uapcursora":{"type":"stdio","command":"./bin/probe","args":["cursor-component"],"cwd":"${PLUGIN_ROOT}"}}}`+"\n") + projectJourneyWrite(t, filepath.Join(source, "bin", "probe"), "inert fixture bytes; never executed\n") + if err := os.Chmod(filepath.Join(source, "bin", "probe"), 0700); err != nil { + t.Fatal(err) + } + } + write("1.0.0") + var iid, oldState string + run := func(stage, verb, selector string, changed bool) { + t.Helper() + var stdout, stderr bytes.Buffer + app := f.app + app.Input, app.Output, app.ErrorOutput, app.Terminal = strings.NewReader(""), &stdout, &stderr, false + root := agentpluginscli.NewRoot(app) + args := []string{verb, selector, "--scope", "project", "--project-root", f.a, "--target", "cursor", "--format", "json"} + if verb == "add" { + args = append(args, "--no-save") + } + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("%s: %v\n%s\n%s", stage, err, stdout.String(), stderr.String()) + } + var row map[string]any + if err := json.Unmarshal(stdout.Bytes(), &row); err != nil { + t.Fatal(err) + } + data := row["data"].(map[string]any) + output := data + if stage == "repeat-add-a" { + if _, present := data["targets"]; present || data["operation_id"] != nil { + t.Fatal("repeat add is not the exact singleton") + } + } else { + if data["batch"] != true || data["succeeded"] != float64(1) || data["failed"] != float64(0) { + t.Fatal("public batch differs", row) + } + output = data["targets"].([]any)[0].(map[string]any)["output"].(map[string]any) + } + outcome := output["result"].(map[string]any) + if iid == "" { + iid = outcome["installation_id"].(string) + } + if outcome["installation_id"] != iid || outcome["mutated"] != changed || outcome["requires_confirmation"] != false { + t.Fatal("held public outcome differs", row) + } + label := "direct local source" + if verb == "repair" || verb == "remove" { + label = "local" + } + if output["source"] != label { + t.Fatal("source label differs", row) + } + if verb != "remove" { + plan, activation := outcome["plan"].(map[string]any), outcome["activation"].(map[string]any) + if plan["verification"] != "package_validated" || activation["verification"] != "installation_verified" || !reflect.DeepEqual(plan["components"], []any{map[string]any{"kind": "mcp_server", "name": "uapcursora", "support": "native"}}) { + t.Fatal("Prepared MCP-only DTO differs", row) + } + } + if stage == "unchanged-update-a" && outcome["no_change"] != true { + t.Fatal("unchanged update flag differs", row) + } + state := projectJourneyRead(t, filepath.Join(f.a, ".uap", "state-v2.json")) + if !changed && state != oldState { + t.Fatal("public no-op rewrote exact state") + } + oldState = state + } + run("add-a", "add", source, true) + run("repeat-add-a", "add", source, false) + run("unchanged-update-a", "update", iid, false) + run("intact-repair-a", "repair", iid, true) + write("1.1.0") + run("update-a", "update", iid, true) + // Remove only the sole owned native document, leaving the package intact. + config := filepath.Join(f.a, ".cursor", "mcp.json") + if err := os.Remove(config); err != nil { + t.Fatal(err) + } + run("missing-repair-a", "repair", iid, true) + run("remove-a", "remove", iid, true) + if _, err := f.execute(f.a, "remove", iid, "--target", "cursor"); err == nil || err.Error() != `plugin is not installed for target "cursor" in project scope; no target was changed` { + t.Fatal("repeated removal refusal differs", err) + } + if projectJourneyRead(t, filepath.Join(f.a, ".uap", "state-v2.json")) != oldState { + t.Fatal("repeated removal changed state") + } +} diff --git a/cli/cmd/agentplugins/cursor_writer_public_contract_test.go b/cli/cmd/agentplugins/cursor_writer_public_contract_test.go new file mode 100644 index 000000000..06f7954b9 --- /dev/null +++ b/cli/cmd/agentplugins/cursor_writer_public_contract_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/cli/internal/agentpluginscli" +) + +// Exercise the same native writer and no-change lifecycle with a tiny remote +// recipe. No endpoint is contacted and no managed stdio launcher is copied. +func TestProjectCursorNativeWriterLockPublicContracts(t *testing.T) { + f := newCursorProjectJourney(t, newProjectJourneyTransport(t, "zip")) + profile := filepath.Join(f.a, ".cursor") + // These are this fixture's known leaves, removed before the new baseline. + for _, name := range []string{"hooks.json", "skills/foreign/SKILL.md", "skills/foreign", "skills", "mcp.json"} { + if err := os.Remove(filepath.Join(profile, filepath.FromSlash(name))); err != nil { + t.Fatal(err) + } + } + projectJourneyWrite(t, filepath.Join(profile, "foreign-owned.txt"), "caller-owned a\n") + var writerLock os.FileInfo + source := projectJourneyTemp(t) + write := func(version string) { + t.Helper() + projectJourneyWrite(t, filepath.Join(source, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"native-proof","version":"`+version+`"}`+"\n") + projectJourneyWrite(t, filepath.Join(source, "mcp.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"uapcursora":{"type":"streamable-http","url":"http://127.0.0.1:9/mcp"}}}`+"\n") + } + write("1.0.0") + var iid, oldState string + run := func(stage, verb, selector string, changed bool) { + t.Helper() + var stdout, stderr bytes.Buffer + app := f.app + app.Input, app.Output, app.ErrorOutput, app.Terminal = strings.NewReader(""), &stdout, &stderr, false + root := agentpluginscli.NewRoot(app) + args := []string{verb, selector, "--scope", "project", "--project-root", f.a, "--target", "cursor", "--format", "json"} + if verb == "add" { + args = append(args, "--no-save") + } + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("%s: %v\n%s\n%s", stage, err, stdout.String(), stderr.String()) + } + var row map[string]any + if err := json.Unmarshal(stdout.Bytes(), &row); err != nil { + t.Fatal(err) + } + data := row["data"].(map[string]any) + output := data + if stage == "repeat-add-a" { + if _, present := data["targets"]; present || data["operation_id"] != nil { + t.Fatal("repeat add is not the exact singleton") + } + } else { + if data["batch"] != true || data["succeeded"] != float64(1) || data["failed"] != float64(0) { + t.Fatal("public batch differs", row) + } + output = data["targets"].([]any)[0].(map[string]any)["output"].(map[string]any) + } + outcome := output["result"].(map[string]any) + keys := map[string]bool{"installation_id": true, "requires_confirmation": true, "mutated": true} + if verb == "remove" { + for _, key := range []string{"plugin", "client_id", "affected_surfaces", "deactivation", "group_phase"} { + keys[key] = true + } + } else { + keys["plan"], keys["activation"] = true, true + if stage != "repeat-add-a" { + keys["group_phase"] = true + } + if stage == "repeat-add-a" || stage == "unchanged-update-a" { + keys["no_change"] = true + if outcome["no_change"] != true { + t.Fatal("observed read-only result lost no_change", stage) + } + } + } + if len(outcome) != len(keys) { + t.Fatal("exact measured public result keys differ", stage, outcome) + } + for key := range outcome { + if !keys[key] { + t.Fatal("unknown public result key", stage, key) + } + } + if stage != "repeat-add-a" && outcome["group_phase"] != "external_completed" { + t.Fatal("measured public group phase differs", stage) + } + if iid == "" { + iid = outcome["installation_id"].(string) + } + if outcome["installation_id"] != iid || outcome["mutated"] != changed || outcome["requires_confirmation"] != false { + t.Fatal("held public outcome differs", row) + } + label := "direct local source" + if verb == "repair" || verb == "remove" { + label = "local" + } + if output["source"] != label { + t.Fatal("source label differs", row) + } + if verb != "remove" { + plan, activation := outcome["plan"].(map[string]any), outcome["activation"].(map[string]any) + if plan["verification"] != "package_validated" || activation["verification"] != "installation_verified" || !reflect.DeepEqual(plan["components"], []any{map[string]any{"kind": "mcp_server", "name": "uapcursora", "support": "native"}}) { + t.Fatal("Prepared MCP-only DTO differs", row) + } + } + if stage == "unchanged-update-a" && outcome["no_change"] != true { + t.Fatal("unchanged update flag differs", row) + } + state := projectJourneyRead(t, filepath.Join(f.a, ".uap", "state-v2.json")) + if !changed && state != oldState { + t.Fatal("public no-op rewrote exact state") + } + oldState = state + writerLock = cursorNativeWriterProfileContract(t, profile, true, writerLock) + } + run("add-a", "add", source, true) + run("repeat-add-a", "add", source, false) + run("unchanged-update-a", "update", iid, false) + run("intact-repair-a", "repair", iid, true) + write("1.1.0") + run("update-a", "update", iid, true) + // Remove only the sole owned native document, leaving the package intact. + config := filepath.Join(f.a, ".cursor", "mcp.json") + if err := os.Remove(config); err != nil { + t.Fatal(err) + } + writerLock = cursorNativeWriterProfileContract(t, profile, false, writerLock) + run("missing-repair-a", "repair", iid, true) + run("remove-a", "remove", iid, true) + if _, err := f.execute(f.a, "remove", iid, "--target", "cursor"); err == nil || err.Error() != `plugin is not installed for target "cursor" in project scope; no target was changed` { + t.Fatal("repeated removal refusal differs", err) + } + if projectJourneyRead(t, filepath.Join(f.a, ".uap", "state-v2.json")) != oldState { + t.Fatal("repeated removal changed state") + } + cursorNativeWriterProfileContract(t, profile, true, writerLock) +} + +func cursorNativeWriterProfileContract(t *testing.T, root string, configured bool, held os.FileInfo) os.FileInfo { + t.Helper() + want := map[string]bool{"foreign-owned.txt": true, "mcp.json.agentplugins.lock": true} + if configured { + want["mcp.json"] = true + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != len(want) { + t.Fatalf("exact public Cursor profile membership: %v, %v", entries, err) + } + for _, entry := range entries { + if !want[entry.Name()] || !entry.Type().IsRegular() { + t.Fatal("unknown or non-ordinary public profile member", entry.Name()) + } + } + path := filepath.Join(root, "mcp.json.agentplugins.lock") + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() != 0 { + t.Fatal("source-defined empty writer lock differs", err) + } + if body, err := os.ReadFile(path); err != nil || len(body) != 0 { + t.Fatal("writer lock body differs", err) + } + // The Linux-tagged frame contract checks exact 0600 and numeric ownership. + // This portable route also preserves the actual lock identity and mode. + if held != nil && (!os.SameFile(held, info) || held.Mode() != info.Mode()) { + t.Fatal("public lifecycle replaced writer lock authority") + } + if projectJourneyRead(t, filepath.Join(root, "foreign-owned.txt")) != "caller-owned a\n" { + t.Fatal("public lifecycle changed the foreign caller file") + } + return info +} diff --git a/docs/plans/project-install-e2e/remaining-native-qualification.md b/docs/plans/project-install-e2e/remaining-native-qualification.md index 2795f03aa..c8497b535 100644 --- a/docs/plans/project-install-e2e/remaining-native-qualification.md +++ b/docs/plans/project-install-e2e/remaining-native-qualification.md @@ -4339,3 +4339,397 @@ report zero issues. Offline readers preserve the qualified17b full verdict and all three retained failed verdicts exactly. The actual28-row stream passes only the corrected cycle decoder; its legacy strict reader still refuses it. These checks run no vendor client and do not replace a fresh hosted complete cycle. +### Cursor measured publisher acquisition transport (2026-10-29) + +The four historical hosted HTTP403 acquisition captures remain failed evidence. +A bounded ordinary publisher-style curl request now receives200 at the same exact +2026.10.22-27dcf5f official URL. Its first1MiB ceiling refuses the declared archive +before downloading bytes. A subsequent authorized static-only download obtains +184859176bytes, SHA256 +`85296944e4600a83c4b764f578d1502bf9c416db200d33a593e8f3ecabfdc294`. +This local download uses the environment's ordinary supplied network proxy; a +closed-environment local control fails DNS before HTTP. Neither result establishes +why the old403 occurred or predicts the genuine hosted closed-environment result. + +Independent complete static inventory has565members,445regular files and +583198758payload bytes in583659520expanded tar bytes, without links, special, +sparse, duplicate or unsafe members. Canonical complete index SHA256 is +`0646a2eff536987e17b914607380879094058ac4f8c6b54d011cf5fe884d7b97`. +The1096-byte `dist-package/cursor-agent` launcher SHA256 is +`2ccc9a8e167797641448b5e5c936f006ba137a2555f117f38c5eb76a5238a233`. +It selects its own126879696-byte sibling Node, SHA256 +`e32e2d16081cf644f88392934288086e7662529d0d43277c34f7aad2207cfafb`, +and7520482-byte sibling index.js, SHA256 +`7b4bb3badb36cff7b6cee7996d61bd3c32866cf354214374b77f5a5d33a0b3f2`. +The immutable archive binds their entire ordinary support-file closure; this is +static inspection, without extraction or execution. Select the actual native +shell launcher, rather than prepending a separately acquired Node. Its leaf-only +640MiB expanded cap fits the existing768MiB admission ceiling and measured size. + +Only this exact Cursor URL selects ordinary curl acquisition. Require curl8.4+ +for the live byte cap, ignore curlrc, use a private HOME and closed environment, +HTTPS only, no redirects, headers or authentication, the original shared budget, +bounded supervised capture and a private ordinary output with held identity +checks. Preserve existing compressed-size, expected-size, digest, inventory and +runtime-file guards. Other clients retain their original urllib transport. The +collector remains acquisition-only and refuses native context access even after +its pin is frozen. All project/MCP/authentication/model flags remain false; actual +hosted acquisition and a separately reviewed read-only collector remain required. + +### Cursor first read-only help and empty-component capture (2026-10-29) + +After reviewed acquisition publicationfd1df496, enable only acquire/help research +phases with the same exact archive, native shell launcher, bundled Node and full +445-file support closure. The pinned index version callback writes exactly +`2026.10.22-27dcf5f` plus LF. Its command table defines top-level help, MCP help, +plugin help and MCP list; it does not define a skills-list command. The exact +2297-byte2601.index.js MCP list module SHA256 is +`6635570334069b0ff14fc4b4e9d629f16478f1a9e8b13c626182874437eaf850`. + +Run the fixed version/top-help/MCP-help/plugin-help table in A, then read-only +empty MCP lists in A, B and the outside directory. Reuse existing30-second +supervised probes, the original shared360-second budget, closed environment, +full runtime identity guards and bounded original stdout/stderr capture. Seed +no native configuration, disabled store, approval, credential or provider. Add +no environment override, updater assumption, login, model, installation, plugin +marketplace mutation, skills command or production detection change. + +Only the exact version, ordinary successful processes and nonempty strict-UTF8 +help can support cursor.help. Retain all three component streams as opaque first +captures; do not infer an empty-catalog grammar, configuration preservation, +server initialization or scope/lifecycle from them. Private disposable-HOME +startup/cache effects remain unqualified and are not adopted as trusted config. +All five project/MCP flags stay false, with scope, authentication and real-model +work explicitly unobserved. Independent actual hosted raw review must establish +the next supported component contract before additional qualification work. + +### Cursor first public project MCP capture, 2026-10-29 + +The genuine seven-probe hosted help capture on `1eb5be73` confirms the pinned +Agent wrapper, version and ordinary `mcp list` surface. The project-only Agent +selection amendment is published at `e231c9e5`; User/editor selection is unchanged. +This next slice reuses the existing runtime acquisition/build/scanner path and +adds one bounded Cursor test. It runs two Git initializations, exact version, +empty A/B/outside MCP listings, one real public project-A MCP-only add with +`--no-save --format json`, then installed-A and B/outside control listings. +There is no credential, approval, trust, provider or private configuration seed. + +The fixture holds all 445 runtime files and the exact public installer, scanner, +probe and Git identities. The current-source add must produce a Prepared/installed +project binding, owned MCP receipt and exact managed stdio recipe. Authored and +selected installed bytes/config/state remain held; original-HOME/B/outside MCP +sources remain absent. Raw MCP output and any harmless owned-probe event bytes +are retained without guessing an installed-list parser or inferring that an +unapproved entry cannot start a process. Unknown native HOME/cache effects are +unqualified and are never adopted as trusted configuration. + +The first installed capture intentionally fails for independent raw review and +keeps every lifecycle, restart/removal and MCP qualification false. It does not +attempt skills discovery, User/B lifecycle, native enable, authentication or +model use. Both independent plan reviews approved the ten literal commands, +Cursor-only reader labels and unchanged descendant supervision/bounds. Local +validation is inert; actual vendor execution remains hosted-only. + +### Cursor exact materialized runtime closure (2026-10-29) + +Actual `bd2a8375` runtime run40898077111/job123542851427 stops in constructor +closure checking, before any Cursor, fixture Git or public installer command. +Only frontend artifact12911120814 is present: 46977 bytes, SHA-256 +`7497501dec2cf21eea5b340b150705e520c53af2fe2f0585f20a6681ddd187bc`; +its inner263726-byte ZIP has25 ordinary CRC-clean members. All12 frontend +commands/24 raw stream joins, clean source/build, exact source/tree, complete +565-row archive inventory and445 runtime file identities were audited. There is +no native artifact or component observation; every qualification remains false. + +The existing materializer writes regular files and creates their parent chains. +Its exact trusted file-derived set is445 files plus114 directory ancestors, +559 live members. Six inventoried empty archive directories are not created; +the original Go expectation incorrectly required all565 archive entries. +Derive the expected live set only from recorded regular-file paths and their +recorded directory ancestors, refusing missing/non-directory parents. Preserve +the full archive pin/inventory validation, all445 held file hashes and the +unchanged exact live-set check. Unknown files, empty directories, aliases and +removals still fail; no observed filesystem entry becomes authority. No shared +materializer, native command, environment, credential or budget changes. + +## Cursor: approve one owned MCP server and capture its tool catalog + +The genuine hosted first component run `40907605330`, target `123579519017`, +source `b8ae9c2a1faa8088ada9c7615bef9024e3dc82ad`, tree +`9b9537a15546c794915da9a8919e3d61fde613bc`, reached all ten commands and the +intentional independent-review fatal. Native artifact `12915339844` is 49,448 +bytes, SHA256 `5d580fb925097f98e3c25a333c3dc5a100bc196206fb6050605c8d0778a892f6`; +frontend `12917210951` is 47,006 bytes, SHA256 +`99f2358f1c14f042113464354c8bef3b693045c6a23eb261140b9adb0abff3a4`. +All three full raw audits close the exact source, 32/25 ordinary members, +24 frontend and 20 native stream joins, full 565-member archive inventory, +445 held runtime file pins and exact 559 materialized members. The owned A +public add is Prepared. Its genuine catalog is exactly +`uapcursora: not loaded (needs approval)`; baseline A/B/outside and later +B/outside controls report the empty sentinel. Probe events are empty. The +17,241-ms observation does not qualify MCP initialization, tools/list, +tools/call, a complete lifecycle, skill loading, authentication or model use. +The historical failed capture and all five false flags remain unchanged. + +The measured pinned MCP help explicitly supports `enable ` and +`list-tools `. One bounded source-only reacquisition verified the +entire same official 184,859,176-byte archive and SHA256 `85296944e4600a83c4b764f578d1502bf9c416db200d33a593e8f3ecabfdc294` +in anonymous RAM, then closed it. No vendor ran or bulk archive was retained. +Only the needed handler/loader source and small authentication/skills excerpts +were retained. This local static transfer used the supplied proxy transport; +it is separate from genuine hosted runtime evidence. + +Exact pinned enable chunk `5993.index.js`, 1,762 bytes, SHA256 +`df1bf13ae26a84f6553bed547dfa024b3be96da6a473e284844f4277d79a825f`, +resolves the actual cwd's git root, merges original User and project MCP +configuration with project precedence, and approves only the exact selected +project server fingerprint. It does not load a client or call a model. The +approval is in the original disposable HOME's +`.cursor/projects//mcp-approvals.json`, not a project +configuration file or a global approval. The source fingerprint is identifier +plus the first 16 hex digits of SHA256 over compact +`{path: projectRoot, server: parsedConfig}`. JSONC/environment expansion and the +pinned Zod parser rebuild stdio fields in schema order `command,args,env` for +this fixture; the record parser preserves its fixed sorted environment keys. +Hash that exact ordered projection of the already held native configuration, +not its raw JSON field order. Optional `type`, cwd, timeout, unknown fields and +unresolved interpolation are absent. The writer appends one fingerprint and +writes a two-space-indented JSON array without a trailing newline. Removing a +disabled record is conditional; this fixture's disabled stores stay absent. + +Pinned tool-list chunk `3575.index.js`, 1,378 bytes, SHA256 +`1b7fb573f4335003d64b3f47c062d5a5a59f4f0a7023bc95184602a45b7d0878`, +loads the selected client through the normal approval-aware loader, checks its +state, obtains its tools and closes it/clears caches on success and failure. +It has no tools/call route. Both independent corrected plan reviews approved +only two extra native calls after the existing unapproved A observation: +`mcp enable uapcursora`, then `mcp list-tools uapcursora`, both in A. All other +ten calls, the original closed environment and descendant supervision remain. + +Require A/B/outside approval and disabled files absent before native execution. +Only that registered native enable may create A's exact one-fingerprint +approval file. Validate its bytes, ordinary 0644 current ownership/group and +single-link identity, then hold it and its ordinary 0755 parent authority +through all later commands/final checks. B/outside approval and every disabled +store stay absent. Original source/config/state/package/data-owner and all +runtime identities stay held. Require probe events still empty after enable; +retain the actual tool-list streams and subsequent intrinsic probe JSONL as +opaque first-capture evidence. Unknown private profile/cache effects remain +unqualified and are not adopted as preserved authority. + +Only the two Cursor-specific member labels and the finite capture member bound +change, from 32 to 38. Child/work/total deadlines, 1-MiB per-member/16-MiB total +bounds and five-second descendant grace remain unchanged. The new twelve-call +capture still deliberately fails for independent raw review and keeps every +runtime/authentication/model flag false. It adds no approval seed, approve-all, +trust, login, provider, credentials, model/chat call, skill command or production +adapter behavior. The normal pinned Agent chat route has a separate account +authentication gate; this supported MCP slice does not need a Cursor key. + +Final corrected code received both independent CODE GOs, including the explicit +join between validated approval Lstat authority and the held snapshot. Seven +focused Cursor Go contracts pass (0.026s), seven focused Cursor Python contracts +pass, and the exact shared workflow/admission module passes all 45 tests +(5.443s). The original complete ten-command failed capture replays identically +and remains all false through the amended reader. Affected all-enabled +whole-file lint against actual `b8ae9c2a` and the unfiltered required six-linter +root gate both report zero issues. The first Go linker attempt hit ENOSPC; +after owner-controlled reproducible-cache cleanup, the same required gate +passed without a source change or a relaxed check. No vendor ran locally. + +## Cursor opaque child argv transport correction + +The source-pinned approval/tools run `40928300509`, job `123647659006`, at +`db13ecd86f5c332d1f1db9680d75304ced183cfa` / tree +`88ad91f2f0bb2b9476603cd23a4636db60bad1f0`, has a truthful failed ten-of-twelve +command prefix. Native artifact `12925726343` is 49,874 bytes, SHA256 +`440dab9fd216f3bf94ad39c3c0463b8af2fdf16c5b676c52800de1ff91e982a0`; +frontend `12925885582` is 46,987 bytes, SHA256 +`9b6fdf3506b947ed6973f850cfa1b9381184f5b73321d4ffc50960142d8594b2`. +All three full RAW audits close API/source/pins, both CRCs, 32/25 ordinary +members, 24 frontend and 20 native stream joins, selected state/config/public +add/data receipts and the exact native-generated A approval fingerprint +`uapcursora-fd6d245f2b5a1b9c`. Enable succeeded; pre-tools and final probe events +are empty. Tool listing exits 1 with the managed-stdio bare/bundled-command +refusal, before initialization. The final review stop and two later B/outside +controls did not execute. Every qualification/authentication/model flag remains +false; the 18,682-ms failed capture is not rewritten as a successful cycle. + +This exposes a product transport defect, not an invalid authored `./bin/probe`. +The unchanged managed-stdio launcher accepts that bundled form and resolves it +through ParseCommand/Resolve; qualified Cline and Copilot fixtures use it. +Pinned Cursor `index.js` remains 7,520,482 bytes, SHA256 +`7b4bb3badb36cff7b6cee7996d61bd3c32866cf354214374b77f5a5d33a0b3f2`. +Its `Oa` function (actual UTF-8 byte offset 4,590,998) rewrites `./`/`.` and +`~/`/`~` values; the local MCP loader maps every argument through it (byte +4,592,151). With no outer native cwd, the held child `./bin/probe` is therefore +forwarded as A's absolute `bin/probe` path. The wrapper correctly refuses it. +The same transformation would damage other supported opaque relative args. +Changing this fixture to a bare PATH runtime would hide that product defect. + +Both independent corrected plans approve one additive form within the existing +private v1 Mode and positional roots. The explicit separator +`--argv-base64-v1` carries each child command/argument as canonical standard +Base64. Only Cursor project projection chooses this form. Plugin/data/cwd and +anchor fields stay exact; every decoded command still goes through the same +cwd anchor, ParseCommand, containment, bare executable lookup and process +replacement rules. No absolute-command relaxation, shell/env executable, PATH +addition, new Mode/namespace, state migration or general transport framework is +introduced. The existing raw `--` producer and other clients remain unchanged. + +One pure decoder serves execution and Cursor installed-form verification. It +rejects unknown separators, missing encoded commands, malformed/noncanonical +Base64, CR/LF in encoded wire tokens and decoded NUL before a child can run. +Decoded CR/LF, empty arguments, Unicode, relative/tildes and environment-looking +text remain opaque. Decoding is linear and cannot expand aggregate incoming +argv bytes; no new arbitrary argument count/length limit is imposed. Selected +Mode/package/data/cwd/anchor checks and native-object/managed-digest ownership +remain intact. Receipt-bound legacy raw installations remain recognized. + +The native fixture retains its authored bundled probe, all twelve commands, +normal one-server approval, original closed environment and 38-member bound. +Only its independently expected installed wire changes. The next genuine frame +still ends at independent review with all flags false. Inert actual-process +contracts model the exact pinned Oa transformation: raw bundled forwarding +fails, while encoded forwarding preserves the copied test executable's Plugin +and Data cwd, opaque argv, stdin/stdout/stderr and exit. Canonical/malformed, +absolute/traversal, selected-root/data/cwd and legacy-form controls protect the +unchanged authority boundaries; these simulations are not vendor evidence. + +## Cursor complete MCP project cycle after the reviewed twelve-command frame + +Runtime `40954740804`, target `123736744311`, on published `ad8b1d3d` / tree +`33397a13` completed all twelve commands and reached only the intended independent +review stop. All three full RAW audits close the 38 native and 25 frontend +members, source/pins, all 24 frontend and 24 native stream joins, public/state/ +native ownership, the exact encoded bundled command, and the native-generated +approval `uapcursora-93cb2b990e8ab114`. Both post-tools controls remain empty. +The 691-byte probe prefix contains two fresh initialize/list sessions with null +facts and no tool call. The original capture remains failed/all false; this is +account-free MCP approval and discovery evidence, not a lifecycle qualification. + +Both independent plans approve one bounded, MCP-only complete-cycle fixture. +Its literal 35 commands are ten public installer commands, two Git setup +commands, the pinned version, and 22 ordinary native list/enable/list-tools +commands. Independent same-name A/B installations have different held package +sources, challenges, installation IDs, physical packages and persistent data. +An ordinary valid empty User MCP document and separate foreign caller files are +authored before the baseline. Native approval is only the supported exact-name +enable command: A1 and B1 each append their own fingerprint, while A2 preserves +A1 and appends the independently computed revised fingerprint in order. Disabled +and outside approval stores stay absent. Repair does not renew approval. + +The sequence covers repeated add and unchanged update without state rewrites, +intact repair, A-only source revision/update, fresh A/B/outside controls, and an +exact sole-owned A native-config absence fault followed by repair. The managed +package remains intact during that fault. Removal and repeated-removal refusal +preserve the caller-authored PLUGIN_DATA marker and owner receipt, while fresh +native observations prove selected absence and the remaining B installation. +The marker is explicitly caller-authored; no vendor marker-write, returned cwd +or opaque-argv fact is inferred from list-tools. + +Only the selected independently validated mutation closure is opened. Unselected +state/package/data, both authored sources except the explicit A revision, User +and foreign files, the held installer/scanner/Git/probe, all 445 native runtime +files and exact 559 materialized members remain guarded around every child. +New selected bytes, receipts, IDs and membership are proven from the recipe +before they become held authority. Unknown profile effects are not adopted. + +Three client-specific JSONL aggregates retain exact command stdout/stderr and +receipts, proof bytes, and every append-prefix probe delta. Existing stream/hash, +strict JSON and bounded aggregation helpers are reused: encoded Base64/JSON +counts toward the unchanged 1-MiB member and 16-MiB total limits. The original +150-second work/180-second test/30-second child budgets and five-second strict +descendant grace remain. The earlier 38-member failed-only reader is preserved. + +A positive summary requires every literal stage, every state/public/approval +join, exact tool/empty output, and at least one fresh initialize then tools/list+ +session per tools command. Every raw event must have the selected nonce, valid +PID/session, command time, null facts and no call; non-tools commands append no +probe bytes. Only a genuine complete capture may qualify the MCP-scoped project +lifecycle, fresh restart/removal, initialization and tools-list flags. Tools-call, +skills, vendor authentication and model use remain unqualified, and stored +activation remains Prepared. No positive native lifecycle is claimed by the +inert contracts or by this source change before its hosted run. + + +### Cursor finite writer-lock frame and exact read-only public result + +The current-source run `40986850597` on `45f562161e710eec41e8d6b50a5253059415c84e` +completed seven ordinary commands through public `add-a`, then failed the exact +expected-tree membership guard. All three complete RAW audits retain that +failure and all qualification flags as false. The generic refusal does not +identify the extra live entry, and no post-add installed proof was uploaded. + +The default native-config writer source derives the exact selected JSON path's +`.agentplugins.lock`, creates an empty ordinary lock, and closes it without +unlinking it. A separate real-Cobra inert HTTP-only reproduction measured that +same persistent empty lock and retained its identity and original mode through +add, no-op, update, intact/missing repair, remove and repeated-remove refusal. +It also measured the exact Cursor singleton repeat-add result: `no_change:true` +and no `group_phase`. This observation uses no endpoint connection, native +client, or managed-stdio launcher execution; the original stdio contract and +hosted 35-command recipe remain unchanged. + +The fixture now derives and authors only those two exact A/B writer locks before +the initial baseline, requires empty ordinary current-owned single-link 0600 +files, and holds their original identity, metadata and digest permanently. The +initial, installed/removed and missing-config frames include those literal +entries. The selected mutation opening never releases them. No User/outside lock, +unknown member, wildcard, runtime write allowance or observed-tree adoption is +introduced. A real default-writer contract uses the same expected-tree predicate +to prove the old finite frame refuses and the exact source-defined frame passes. + +Go and Python require the measured stage-specific result fields: repeat-add has +six including true `no_change` and no group phase; unchanged-update has seven +including true `no_change` and `external_completed`; other non-removal mutations +have six including that phase and omit `no_change`. The existing exact removal +result, ownership/source joins, native commands, deadlines, aggregate bounds and +qualification rules remain unchanged. A fresh complete hosted cycle and full RAW +review are still required before any lifecycle qualification. + + +### Cursor admission refusal callsite diagnostic, 2026-10-30 + +The genuine `70048a6c` run `41039188565` reports the selected native Go test +passing in 86.84 seconds, but strict Python artifact admission then raises +`ValueError`. The verified frontend contains all twelve successful source/build/ +test commands; the rejected native ZIP was not uploaded. All qualification +flags remain false. The generic exception type does not identify the rejected +predicate, so no native RAW or exact cause is inferred from the producer PASS. + +On Cursor admission refusal only, emit one bounded diagnostic to existing job +stderr: a fixed owned reader basename and source line, or a fixed unavailable +fallback. Match exact owned script paths, inspect only the explicit cause and +outer traceback with finite traversal, and exclude generic require/object +helpers. Never print exception messages, locals, source text, input, unknown +paths or a full stack. The frontend schema, original refusal, flags and native +ZIP rejection remain unchanged; other clients are unaffected. A fresh changed- +source hosted run must reveal the actual site before any corrective reader +change or qualification. + + +### Cursor approval-proof container admission, 2026-10-30 + +The `af549028` run `41043633550` again reports the selected native Go test +passing, this time in 105.67 seconds. The bounded refusal diagnostic identifies +`project_native_cursor_cycle.py:67`, the exact raw-stream key check. The verified +frontend remains unqualified and the rejected native ZIP is unavailable. + +The Go approval producer emits an outer proof with exactly `scope`, `path`, +`body`, `fingerprints`, `probe_bytes` and `probe_sha256`; its `body` is itself a +canonical stream receipt. The generic embedded-stream traversal treated the +outer proof's `body` key as a stream marker and rejected that outer dictionary. +A literal producer-shaped inert input reproduces the same refusal site. The +previous complete-cycle positive called the semantic checker directly and did +not exercise serialized record traversal. + +Only the three exact approval stages in the fixed proof order now close that +six-key outer container and explicitly validate its inner stream. All other +embedded/raw-stream handling remains unchanged. A full four-member serialized +ZIP admission positive covers the original missing path; finite-stage, +malformed-container, malformed-inner-stream and foreign-role negatives preserve +refusal. Native commands, Go guards, source/approval/state/probe joins, flags, +deadlines, pins and capture bounds are unchanged. Historical captures retain +false qualification; a fresh admitted full native packet still requires all +independent RAW audits before lifecycle qualification. diff --git a/install/integrationctl/agentplugins/adapters/clientdetect/cursor_project_registry_test.go b/install/integrationctl/agentplugins/adapters/clientdetect/cursor_project_registry_test.go new file mode 100644 index 000000000..1bf23b9aa --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/clientdetect/cursor_project_registry_test.go @@ -0,0 +1,172 @@ +package clientdetect + +import ( + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/clients/all" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// These are inert host probes through the actual registries and detector, not +// execution of an editor, Agent, package payload or native-registry command. +func TestCursorProjectRegistrySelectionPreservesUserAuthority(t *testing.T) { + for _, row := range []struct { + name string + editor, agent bool + config, invalid bool + }{ + {name: "agent", agent: true}, + {name: "editor", editor: true}, + {name: "both", editor: true, agent: true}, + {name: "config", config: true}, + {name: "absent"}, + {name: "invalid-profile", editor: true, agent: true, invalid: true}, + } { + t.Run(row.name, func(t *testing.T) { + home := cursorSelectionHome(t, row.config, row.invalid) + binaries := map[string]string{} + if row.editor { + binaries["cursor"] = filepath.Join(home, "editor") + } + if row.agent { + binaries["cursor-agent"] = filepath.Join(home, "agent") + } + user := cursorSelectionClient(t, cursorSelectionDetector(home, all.Default(), binaries), false) + project := cursorSelectionClient(t, cursorSelectionDetector(home, all.Project(), binaries), false) + assertCursorSelection(t, user, row.editor && !row.invalid, row.invalid, home, binaries["cursor"]) + assertCursorSelection(t, project, (row.editor || row.agent) && !row.invalid, row.invalid, home, binaries["cursor"]) + if !reflect.DeepEqual(user.Surfaces, project.Surfaces) { + t.Fatal("project selection changed raw editor/Agent/profile evidence") + } + }) + } +} + +func TestCursorProjectAgentSelectionNeverBorrowsExecutableOrVersion(t *testing.T) { + home := cursorSelectionHome(t, false, false) + detector := cursorSelectionDetector(home, all.Project(), map[string]string{"cursor-agent": filepath.Join(home, "agent")}) + detector.ProbeVersion = func(context.Context, string) (string, error) { + t.Fatal("Agent-only project selection invoked an editor version probe") + return "", nil + } + client := cursorSelectionClient(t, detector, true) + assertCursorSelection(t, client, true, false, home, "") + inspector, ok := clients.As[clients.RegistryInspector](all.Project(), domain.ClientCursor) + if !ok || inspector.UsesNativeRegistryExecutable() { + t.Fatal("project components gained a native executable registry probe") + } + adapter, ok := clients.As[clients.NativeRegistryLayout](all.Project(), domain.ClientCursor) + if !ok { + t.Fatal("project Cursor registry layout disappeared") + } + project := filepath.Join(home, "project") + if err := os.Mkdir(project, 0o700); err != nil { + t.Fatal(err) + } + root, executable := adapter.NativeRegistry(clients.PlanInput{Client: client, TargetContext: domain.TargetContext{Scope: domain.ScopeProject, ProjectRoot: project, UserConfigRoot: client.ConfigRoot}}) + if root != filepath.Join(project, ".cursor") || executable != "" { + t.Fatalf("project native registry borrowed Agent identity: root=%q executable=%q", root, executable) + } +} + +func TestCursorProjectRegistryKeepsPreparedAndAttestationBoundary(t *testing.T) { + home := cursorSelectionHome(t, false, false) + registry := all.Project() + client := cursorSelectionClient(t, cursorSelectionDetector(home, registry, map[string]string{"cursor-agent": filepath.Join(home, "agent")}), false) + project := filepath.Join(home, "project") + target := domain.TargetContext{Scope: domain.ScopeProject, ProjectRoot: project, UserConfigRoot: client.ConfigRoot} + plan := domain.DeliveryPlan{Scope: domain.ScopeProject, TargetContext: target, Status: domain.PlanReady, + NativeRegistryRoot: filepath.Join(project, ".cursor"), + Components: []domain.ComponentDecision{{Kind: domain.ComponentSkill, Name: "inert", Support: domain.SupportNative}}} + refiner, ok := clients.As[clients.PlanRefiner](registry, domain.ClientCursor) + if !ok { + t.Fatal("project Cursor planning capability disappeared") + } + if err := refiner.RefinePlan(context.Background(), clients.PlanInput{Client: client, TargetContext: target}, &plan); err != nil || plan.Activation != domain.ActivationPrepared { + t.Fatalf("Agent presence upgraded project configuration beyond Prepared: %+v, %v", plan, err) + } + preflight, ok := clients.As[clients.ActivationPreflighter](registry, domain.ClientCursor) + if !ok { + t.Fatal("project Cursor activation preflight disappeared") + } + err := preflight.PreflightActivation(clients.Env{}, domain.ActivationRequest{Client: client, Plan: plan, ActivationComplete: true}) + if err == nil || !strings.Contains(err.Error(), "cannot be established by activation attestation") { + t.Fatalf("Agent presence bypassed native discovery attestation refusal: %v", err) + } +} + +func cursorSelectionHome(t *testing.T, config, invalid bool) string { + t.Helper() + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + root := filepath.Join(home, ".cursor") + if invalid { + if err := os.WriteFile(root, []byte("inert non-directory profile"), 0o600); err != nil { + t.Fatal(err) + } + } else if config { + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + } + return home +} + +func cursorSelectionDetector(home string, registry *clients.Registry, binaries map[string]string) Detector { + return Detector{ + HomeDir: home, WorkingDir: home, GOOS: "linux", Registry: registry, + Lstat: os.Lstat, ReadDir: os.ReadDir, EvalSymlinks: filepath.EvalSymlinks, + LookPath: func(name string) (string, error) { + if path := binaries[name]; path != "" { + return path, nil + } + return "", fmt.Errorf("inert absent binary %s", name) + }, + } +} + +func cursorSelectionClient(t *testing.T, detector Detector, probe bool) domain.DetectedClient { + t.Helper() + var detected []domain.DetectedClient + var err error + if probe { + detected, err = detector.DetectTargetsWithVersionProbe(context.Background(), []domain.ClientID{domain.ClientCursor}) + } else { + detected, err = detector.Detect(context.Background()) + } + if err != nil { + t.Fatal(err) + } + for _, client := range detected { + if client.ClientID == domain.ClientCursor { + return client + } + } + t.Fatal("real registry lost Cursor") + return domain.DetectedClient{} +} + +func assertCursorSelection(t *testing.T, client domain.DetectedClient, detected, invalid bool, home, editor string) { + t.Helper() + if (client.Status == domain.DetectionDetected) != detected || (client.DetectionError != nil) != invalid { + t.Fatalf("Cursor selection or profile-error precedence changed: %+v", client) + } + if invalid { + if client.ConfigRoot != "" || client.ExecutablePath != "" || client.Version != "" || len(client.Surfaces) != 0 { + t.Fatalf("invalid Cursor profile retained authority: %+v", client) + } + return + } + if client.ConfigRoot != filepath.Join(home, ".cursor") || client.ExecutablePath != editor || client.Version != "" { + t.Fatalf("Cursor project selection replaced editor/profile identity: %+v", client) + } +} diff --git a/install/integrationctl/agentplugins/clients/all/registry.go b/install/integrationctl/agentplugins/clients/all/registry.go index e2e68932c..2743123f0 100644 --- a/install/integrationctl/agentplugins/clients/all/registry.go +++ b/install/integrationctl/agentplugins/clients/all/registry.go @@ -45,14 +45,16 @@ var projectRegistry = sync.OnceValue(func() *clients.Registry { return buildRegi func buildRegistry(project bool) *clients.Registry { var workspace clients.Adapter = vscode.New() + var cursorComponents clients.Adapter = cursor.New() if project { workspace = vscode.NewProject() + cursorComponents = cursor.NewProject() } registry, err := clients.NewRegistry( pi.New(), codex.New(), chatgpt.New(), - cursor.New(), + cursorComponents, copilot.New(), workspace, kiro.New(), diff --git a/install/integrationctl/agentplugins/clients/cursor/README.md b/install/integrationctl/agentplugins/clients/cursor/README.md index 71ff06df0..1406d17b7 100644 --- a/install/integrationctl/agentplugins/clients/cursor/README.md +++ b/install/integrationctl/agentplugins/clients/cursor/README.md @@ -96,3 +96,8 @@ These source/attributed startup observations prove no genuine Stop, native MCP request, restart discovery, Windows native I/O, or release availability. S4 needs the supplied official artifact to be available for an isolated TEST native discovery run. Independent code review precedes public delivery. + +Project MCP stdio entries use the private launcher's explicit encoded argv form +to preserve bundled commands and opaque arguments through Cursor normalization. +The same strict decoder validates installed entries and execution; selected +package/data/cwd authority and legacy raw entries retain their existing checks. diff --git a/install/integrationctl/agentplugins/clients/cursor/detect.go b/install/integrationctl/agentplugins/clients/cursor/detect.go index c3cc654d6..e1a49f233 100644 --- a/install/integrationctl/agentplugins/clients/cursor/detect.go +++ b/install/integrationctl/agentplugins/clients/cursor/detect.go @@ -9,11 +9,15 @@ import ( ) // Adapter implements the client contract for Cursor. -type Adapter struct{} +type Adapter struct{ projectComponents bool } // New builds the adapter. The composition root registers it in clients/all. func New() *Adapter { return &Adapter{} } +// NewProject selects project components using either the editor or Agent +// surface. It does not qualify native discovery or replace editor identity. +func NewProject() *Adapter { return &Adapter{projectComponents: true} } + var ( _ clients.Adapter = (*Adapter)(nil) _ clients.HostDetector = (*Adapter)(nil) @@ -25,7 +29,7 @@ func (*Adapter) ID() domain.ClientID { return domain.ClientCursor } // DetectSurfaces reports editor and agent binary presence separately. The // retained package preparation contract is for the editor; neither presence // nor a leftover directory qualifies native agent plugins or Stop events. -func (*Adapter) DetectSurfaces(host clients.Host) clients.Detection { +func (adapter *Adapter) DetectSurfaces(host clients.Host) clients.Detection { configRoot, err := detectedProfileRoot(host) if err != nil { return clients.Detection{Err: err} @@ -43,10 +47,14 @@ func (*Adapter) DetectSurfaces(host clients.Host) clients.Detection { } else if host.GOOS() == "linux" { surfaces = append(surfaces, host.LinuxDesktopSurface("cursor_desktop", "cursor.desktop")) } + selection := []string{"cursor_editor", "cursor_desktop"} + if adapter.projectComponents { + selection = append(selection, "cursor_agent") + } return clients.Detection{ ConfigRoot: configRoot, ExecutablePath: editor, Surfaces: surfaces, - SelectionSurfaceIDs: []string{"cursor_editor", "cursor_desktop"}, + SelectionSurfaceIDs: selection, } } diff --git a/install/integrationctl/agentplugins/clients/cursor/project_stdio.go b/install/integrationctl/agentplugins/clients/cursor/project_stdio.go index dbaae5e92..08cb616fc 100644 --- a/install/integrationctl/agentplugins/clients/cursor/project_stdio.go +++ b/install/integrationctl/agentplugins/clients/cursor/project_stdio.go @@ -77,7 +77,7 @@ func bindCursorStdio(entry map[string]any, server domain.MCPServer, activePath, command = "./" + strings.TrimPrefix(command, "${package.root}/") } entry["command"] = filepath.Join(activePath, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) - entry["args"] = managedstdio.Arguments(activePath, dataPath, absoluteCWD, cwd.Anchor, command, args) + entry["args"] = managedstdio.EncodedArguments(activePath, dataPath, absoluteCWD, cwd.Anchor, command, args) delete(entry, "cwd") return nil } diff --git a/install/integrationctl/agentplugins/clients/cursor/project_stdio_contract_test.go b/install/integrationctl/agentplugins/clients/cursor/project_stdio_contract_test.go new file mode 100644 index 000000000..eb8a06953 --- /dev/null +++ b/install/integrationctl/agentplugins/clients/cursor/project_stdio_contract_test.go @@ -0,0 +1,94 @@ +package cursor + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/managedstdio" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/pathcontract" +) + +func TestProjectStdioPreservesOpaqueVectorInEncodedProjection(t *testing.T) { + active, data := filepath.Join(testBase(t), "package"), filepath.Join(testBase(t), "data") + args := []any{"", ".", "./relative", "~/relative", "${UNKNOWN}", "雪", "\r\n"} + entry := map[string]any{"cwd": active, "args": args} + source := domain.MCPServer{Type: "stdio", Decoded: map[string]any{"command": "./bin/probe", "cwd": "${PLUGIN_ROOT}"}} + if err := bindCursorStdio(entry, source, active, data); err != nil { + t.Fatal(err) + } + wire, ok := entry["args"].([]string) + if !ok || len(wire) != 14 || wire[5] != "--argv-base64-v1" || wire[6] != "Li9iaW4vcHJvYmU=" { + t.Fatal("Cursor did not project the explicit encoded transport") + } + decoded, err := managedstdio.DecodeCommandArguments(wire[5], wire[6:]) + want := []string{"./bin/probe", "", ".", "./relative", "~/relative", "${UNKNOWN}", "雪", "\r\n"} + if err != nil || !reflect.DeepEqual(decoded, want) { + t.Fatal("Cursor projected child vector changed", err) + } + if _, exists := entry["cwd"]; exists || entry["command"] != filepath.Join(active, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) { + t.Fatal("Cursor bypassed managed cwd/launcher authority") + } +} + +func TestProjectStdioVerificationAcceptsOwnedLegacyAndEncodedForms(t *testing.T) { + root := testBase(t) + active, data := filepath.Join(root, "active"), filepath.Join(root, "data") + for _, path := range []string{active, data} { + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } + } + envelope := domain.PackageEnvelope{MCP: domain.MCPComponent{Servers: map[string]domain.MCPServer{"probe": {Type: "stdio", Decoded: map[string]any{"command": "./bin/probe", "cwd": "${PLUGIN_ROOT}"}}}}} + plan := domain.DeliveryPlan{ActivePath: active, Components: []domain.ComponentDecision{{Kind: domain.ComponentMCPServer, Name: "probe", Support: domain.SupportNative}}} + legacy := managedstdio.Arguments(active, data, active, pathcontract.Plugin, "./bin/probe", []string{"./relative", "\n"}) + encoded := managedstdio.EncodedArguments(active, data, active, pathcontract.Plugin, "./bin/probe", []string{"./relative", "\n"}) + for _, fault := range []string{"legacy", "encoded", "mode", "plugin", "data", "cwd", "anchor", "separator", "missing-command", "malformed-command", "malformed-tail", "wire-newline", "decoded-nul"} { + t.Run(fault, func(t *testing.T) { + args := append([]string(nil), encoded...) + switch fault { + case "legacy": + args = append([]string(nil), legacy...) + case "mode": + args[0] = "--unknown" + case "plugin": + args[1] = filepath.Join(root, "foreign") + case "data": + args[2] = filepath.Join(root, "foreign") + case "cwd": + args[3] = data + case "anchor": + args[4] = "data" + case "separator": + args[5] = "--unknown" + case "missing-command": + args = args[:6] + case "malformed-command": + args[6] = "?" + case "malformed-tail": + args[len(args)-1] = "?" + case "wire-newline": + args[6] += "\n" + case "decoded-nul": + args[len(args)-1] = "AA==" + } + entry := map[string]any{"command": filepath.Join(active, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName), "args": args, "env": map[string]string{"PLUGIN_ROOT": active, "PLUGIN_DATA": data}} + body, err := json.Marshal(map[string]any{"mcpServers": map[string]any{"probe": entry}}) + if err != nil || os.WriteFile(filepath.Join(active, "mcp.json"), body, 0600) != nil { + t.Fatal("inert Cursor projection fixture", err) + } + err = validateCursorProjectStdio(active, envelope, plan, data) + if fault == "legacy" || fault == "encoded" { + if err != nil { + t.Fatal("owned supported wire was orphaned", err) + } + } else if !errors.Is(err, domain.ErrProjectionRefreshRequired) { + t.Fatal("malformed wire or wrong selected locator was verified", err) + } + }) + } +} diff --git a/install/integrationctl/agentplugins/clients/cursor/project_stdio_verify.go b/install/integrationctl/agentplugins/clients/cursor/project_stdio_verify.go index d5f81ec14..a244ba8e4 100644 --- a/install/integrationctl/agentplugins/clients/cursor/project_stdio_verify.go +++ b/install/integrationctl/agentplugins/clients/cursor/project_stdio_verify.go @@ -36,10 +36,13 @@ func validateCursorProjectStdio(root string, envelope domain.PackageEnvelope, pl if err != nil { return err } - prefix := managedstdio.Arguments(plan.ActivePath, dataPath, resolvedCWD, cwd.Anchor, "", nil)[:6] - if len(server.Args) < 7 || !slices.Equal(server.Args[:6], prefix) { + prefix := managedstdio.Arguments(plan.ActivePath, dataPath, resolvedCWD, cwd.Anchor, "", nil)[:5] + if len(server.Args) < 7 || !slices.Equal(server.Args[:5], prefix) { return fmt.Errorf("%w: Cursor launcher package/data/cwd arguments differ from selected locators", domain.ErrProjectionRefreshRequired) } + if _, err := managedstdio.DecodeCommandArguments(server.Args[5], server.Args[6:]); err != nil { + return fmt.Errorf("%w: Cursor launcher child argument transport differs", domain.ErrProjectionRefreshRequired) + } } return nil } diff --git a/install/integrationctl/agentplugins/managedstdio/arguments.go b/install/integrationctl/agentplugins/managedstdio/arguments.go new file mode 100644 index 000000000..739fa549b --- /dev/null +++ b/install/integrationctl/agentplugins/managedstdio/arguments.go @@ -0,0 +1,49 @@ +package managedstdio + +import ( + "encoding/base64" + "fmt" + "strings" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/pathcontract" +) + +const encodedArgumentsSeparator = "--argv-base64-v1" + +// EncodedArguments preserves opaque child arguments through clients that expand +// relative paths or environment-looking strings in every MCP argument. +func EncodedArguments(plugin, data, cwd string, anchor pathcontract.Anchor, command string, args []string) []string { + result := Arguments(plugin, data, cwd, anchor, command, args) + result[5] = encodedArgumentsSeparator + for i := 6; i < len(result); i++ { + result[i] = base64.StdEncoding.EncodeToString([]byte(result[i])) + } + return result +} + +// DecodeCommandArguments validates the explicit private transport. Decoding is +// linear in the incoming argv bytes and never expands their aggregate size. +// The legacy raw separator retains its original argument semantics. +func DecodeCommandArguments(separator string, args []string) ([]string, error) { + if len(args) == 0 { + return nil, fmt.Errorf("invalid protocol v1 arguments") + } + if separator == "--" { + return args, nil + } + if separator != encodedArgumentsSeparator { + return nil, fmt.Errorf("invalid protocol v1 arguments") + } + decoded := make([]string, len(args)) + for i, value := range args { + raw, err := base64.StdEncoding.Strict().DecodeString(value) + if err != nil || strings.ContainsAny(value, "\r\n") || base64.StdEncoding.EncodeToString(raw) != value || strings.IndexByte(string(raw), 0) >= 0 { + return nil, fmt.Errorf("invalid encoded protocol v1 argument") + } + decoded[i] = string(raw) + } + if decoded[0] == "" { + return nil, fmt.Errorf("missing encoded protocol v1 command") + } + return decoded, nil +} diff --git a/install/integrationctl/agentplugins/managedstdio/arguments_test.go b/install/integrationctl/agentplugins/managedstdio/arguments_test.go new file mode 100644 index 000000000..fa42dc094 --- /dev/null +++ b/install/integrationctl/agentplugins/managedstdio/arguments_test.go @@ -0,0 +1,125 @@ +package managedstdio + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/pathcontract" +) + +func TestEncodedArgumentsCanonicalRoundTripAndRefusal(t *testing.T) { + opaque := []string{"", ".", "./relative", "./../opaque", "~", "~/relative", "${UNKNOWN}", "雪", "a b", "\r\n", "--flag"} + wire := EncodedArguments("/plugin", "/data", "/plugin", pathcontract.Plugin, "./child", opaque) + if !reflect.DeepEqual(wire[:6], []string{Mode, "/plugin", "/data", "/plugin", "plugin", "--argv-base64-v1"}) || wire[6] != "Li9jaGlsZA==" { + t.Fatal("encoded v1 fields differ") + } + got, err := DecodeCommandArguments(wire[5], wire[6:]) + if err != nil || !reflect.DeepEqual(got, append([]string{"./child"}, opaque...)) { + t.Fatal("opaque child vector changed", err) + } + for _, bad := range [][]string{nil, {""}, {"?"}, {"Li9jaGlsZA==\n"}, {"Li9jaGlsZA==\r\n"}, {"Zh=="}, {"Li9jaGlsZA==", "ZgA="}} { + if _, err := DecodeCommandArguments("--argv-base64-v1", bad); err == nil { + t.Fatal("malformed/noncanonical/NUL encoded vector accepted") + } + } + if _, err := DecodeCommandArguments("--unknown", []string{"child"}); err == nil { + t.Fatal("unknown protocol separator accepted") + } + raw, err := DecodeCommandArguments("--", append([]string{"./child"}, opaque...)) + if err != nil || !reflect.DeepEqual(raw, append([]string{"./child"}, opaque...)) { + t.Fatal("legacy raw vector changed") + } +} + +// Literal source model of pinned Cursor index.js Oa, not a vendor process. +func cursorNormalizedArguments(args []string, cwd, home string) []string { + result := append([]string(nil), args...) + for i, value := range result { + switch { + case value == "~" || strings.HasPrefix(value, "~/"): + result[i] = filepath.Join(home, strings.TrimPrefix(value, "~")) + case value == "." || strings.HasPrefix(value, "./"): + result[i] = filepath.Join(cwd, value) + } + } + return result +} + +func TestEncodedExecPreservesCursorNormalizedOpaqueArguments(t *testing.T) { + if !Supported() { + t.Skip("unsupported native lifecycle") + } + for _, anchor := range []pathcontract.Anchor{pathcontract.Plugin, pathcontract.Data} { + t.Run(string(anchor), func(t *testing.T) { + plugin, data, clientCWD, home := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir() + root := plugin + if anchor == pathcontract.Data { + root = data + } + if err := os.WriteFile(filepath.Join(root, "relative.txt"), []byte("relative-ok"), 0600); err != nil { + t.Fatal(err) + } + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plugin, "child"), body, 0700); err != nil { + t.Fatal(err) + } + opaque := []string{"", ".", "./relative", "./../opaque", "~", "~/relative", "${UNKNOWN}", "雪", "a b", "\r\n", "$(touch forbidden)", "--flag"} + childArgs := append([]string{"-test.run=^TestSubprocess$", "fixture", "child"}, opaque...) + raw := cursorNormalizedArguments(Arguments(plugin, data, root, anchor, "./child", childArgs), clientCWD, home) + failed := fixtureCommand(t, raw) + if output, err := failed.CombinedOutput(); err == nil || !bytes.Contains(output, []byte("command must be a bare executable or bundled path")) { + t.Fatal("unencoded Cursor-normalized bundled command did not reproduce refusal", err) + } + wire := EncodedArguments(plugin, data, root, anchor, "./child", childArgs) + transported := cursorNormalizedArguments(wire, clientCWD, home) + if !reflect.DeepEqual(transported, wire) { + t.Fatal("Cursor normalization altered encoded fields") + } + cmd := fixtureCommand(t, transported) + cmd.Stdin = strings.NewReader("roundtrip\n") + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err = cmd.Run() + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 23 { + t.Fatal("encoded child exit differs", err, stderr.String()) + } + var got struct { + CWD, File, Stdin, Env string + Args []string + } + canonical, err := filepath.EvalSymlinks(root) + if err != nil || json.Unmarshal(stdout.Bytes(), &got) != nil || got.CWD != canonical || got.File != "relative-ok" || got.Stdin != "roundtrip\n" || !reflect.DeepEqual(got.Args, opaque) || got.Env != "${PLUGIN_ROOT};$(untouched)" || stderr.String() != "child-stderr" { + t.Fatal("encoded process changed cwd/argv/streams/environment") + } + }) + } +} + +func TestEncodedArgumentsCannotRelaxCommandContainment(t *testing.T) { + root, data := t.TempDir(), t.TempDir() + for _, command := range []string{"/bin/sh", "../escape", "./../escape", "./missing"} { + cmd := fixtureCommand(t, EncodedArguments(root, data, root, pathcontract.Plugin, command, nil)) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 126 || stdout.Len() != 0 || stderr.Len() == 0 { + t.Fatal("encoded command escaped unchanged path guard", err) + } + } +} diff --git a/install/integrationctl/agentplugins/managedstdio/launcher.go b/install/integrationctl/agentplugins/managedstdio/launcher.go index 55363d768..ee4ef327f 100644 --- a/install/integrationctl/agentplugins/managedstdio/launcher.go +++ b/install/integrationctl/agentplugins/managedstdio/launcher.go @@ -29,7 +29,7 @@ func Dispatch(args []string, stderr io.Writer) (bool, int) { return false, 0 } if err := run(args[1:]); err != nil { - fmt.Fprintln(stderr, "managed stdio:", err) + _, _ = fmt.Fprintln(stderr, "managed stdio:", err) return true, 126 } return true, 0 @@ -39,19 +39,21 @@ func run(args []string) error { if !Supported() { return fmt.Errorf("managed stdio is unsupported on this platform") } - if len(args) < 6 || args[4] != "--" { + if len(args) < 6 { return fmt.Errorf("invalid protocol v1 arguments") } + child, err := DecodeCommandArguments(args[4], args[5:]) + if err != nil { + return err + } plugin, data, cwd := args[0], args[1], args[2] if !filepath.IsAbs(plugin) || !filepath.IsAbs(data) || !filepath.IsAbs(cwd) { return fmt.Errorf("protocol roots and cwd must be absolute") } root := plugin - switch pathcontract.Anchor(args[3]) { - case pathcontract.Plugin: - case pathcontract.Data: + if anchor := pathcontract.Anchor(args[3]); anchor == pathcontract.Data { root = data - default: + } else if anchor != pathcontract.Plugin { return fmt.Errorf("invalid cwd anchor") } // Strip without Clean/Rel: symlink/.. traversal must reach the resolver intact. @@ -65,13 +67,13 @@ func run(args []string) error { } resolved := pathcontract.Resolve(root, relative) if resolved.State != pathcontract.Resolved { - return fmt.Errorf("cwd unavailable: %v", resolved.Err) + return fmt.Errorf("cwd unavailable: %w", resolved.Err) } info, err := os.Stat(resolved.Path) if err != nil || !info.IsDir() { return fmt.Errorf("cwd is not a directory") } - command := args[5] + command := child[0] if strings.HasPrefix(command, "./") { relative, err := pathcontract.ParseCommand(command) if err != nil { @@ -79,7 +81,7 @@ func run(args []string) error { } result := pathcontract.Resolve(plugin, relative) if result.State != pathcontract.Resolved { - return fmt.Errorf("bundled command unavailable: %v", result.Err) + return fmt.Errorf("bundled command unavailable: %w", result.Err) } command = result.Path } else { @@ -95,5 +97,5 @@ func run(args []string) error { return fmt.Errorf("runtime PATH resolved to a relative executable") } } - return replaceProcess(command, append([]string{command}, args[6:]...), resolved.Path) + return replaceProcess(command, append([]string{command}, child[1:]...), resolved.Path) } diff --git a/install/integrationctl/agentplugins/providers/project_cursor_stdio_test.go b/install/integrationctl/agentplugins/providers/project_cursor_stdio_test.go index e6580616d..90bf2a198 100644 --- a/install/integrationctl/agentplugins/providers/project_cursor_stdio_test.go +++ b/install/integrationctl/agentplugins/providers/project_cursor_stdio_test.go @@ -2,6 +2,7 @@ package providers_test import ( "context" + "encoding/base64" "encoding/json" "errors" "os" @@ -37,7 +38,8 @@ func TestProjectCursorStdioUsesBoundManagedLauncherWithoutExecution(t *testing.T server := document.Servers["docs"] active := fixture.request.Plan.ActivePath data := filepath.Join(fixture.request.Plan.TargetContext.ProjectRoot, ".uap", "data", fixture.request.Plan.PhysicalArtifactID) - wantArgs := []string{managedstdio.Mode, active, data, active, "plugin", "--", "node", active + "/server.js", data} + wantArgs := []string{managedstdio.Mode, active, data, active, "plugin", "--argv-base64-v1", "bm9kZQ==", + base64.StdEncoding.EncodeToString([]byte(active + "/server.js")), base64.StdEncoding.EncodeToString([]byte(data))} if server.Command != filepath.Join(active, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) || !reflect.DeepEqual(server.Args, wantArgs) || server.CWD != "" || server.Env["PLUGIN_ROOT"] != active || server.Env["PLUGIN_DATA"] != data { t.Fatalf("native launcher binding = %+v; want args %q", server, wantArgs) } diff --git a/repotests/agentplugins_cursor_cycle_approval_test.go b/repotests/agentplugins_cursor_cycle_approval_test.go new file mode 100644 index 000000000..a34f3eef0 --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_approval_test.go @@ -0,0 +1,106 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "syscall" + "testing" +) + +func (c *cursorMCPProjectCycle) approvalRecipe(r *cursorCycleRecipe) (string, []byte, []string, error) { + config := cursorCycleJSON(map[string]any{"mcpServers": map[string]any{"uapcursora": c.server(r)}}) + path, one, err := cursorApprovalRecipe(c.f.base.Home, r.Project, config) + var current []string + if err != nil || json.Unmarshal(one, ¤t) != nil || len(current) != 1 { + return "", nil, nil, fmt.Errorf("Cursor expected approval fingerprint differs") + } + ids := append([]string{}, c.approvals[r.Scope]...) + for _, id := range ids { + if id == current[0] { + return "", nil, nil, fmt.Errorf("Cursor approval must add one fresh expected revision") + } + } + ids = append(ids, current[0]) + body, err := json.MarshalIndent(ids, "", " ") + return path, body, ids, err +} + +func (c *cursorMCPProjectCycle) openApproval(t *testing.T, r *cursorCycleRecipe) { + t.Helper() + c.check(t) + path, _, ids, err := c.approvalRecipe(r) + wantCount := 1 + if r.Version == "1.1.0" { + wantCount = 2 + } + if err != nil || len(ids) != wantCount { + t.Fatal("Cursor native approval phase/order differs", err) + } + if len(c.approvals[r.Scope]) == 0 { + found := false + for _, absent := range c.f.absent { + found = found || absent == path + } + if _, err := os.Lstat(path); !found || !os.IsNotExist(err) { + t.Fatal("Cursor initial approval absence differs") + } + } else { + previous, _ := json.MarshalIndent(c.approvals[r.Scope], "", " ") + raw, err := projectNativeRuntimeRead(path, 4096) + if err != nil || !bytes.Equal(raw, previous) || c.f.held[path].info == nil { + t.Fatal("Cursor old approval bytes/authority differ", err) + } + c.priorApproval[r.Scope] = c.f.held[path] + delete(c.f.held, path) + } +} + +func (c *cursorMCPProjectCycle) acceptApproval(t *testing.T, r *cursorCycleRecipe, stage string) { + t.Helper() + path, expected, ids, err := c.approvalRecipe(r) + body, readErr := projectNativeRuntimeRead(path, 4096) + info, statErr := os.Lstat(path) + if err != nil || readErr != nil || statErr != nil || info.Mode() != 0644 || !bytes.Equal(body, expected) { + t.Fatal("Cursor native approval exact bytes/mode differ") + } + if before := c.priorApproval[r.Scope]; before.info != nil && !cursorCycleDirectorySame(before.info, info) { + t.Fatal("Cursor native approval replaced original file authority") + } + if err := c.acceptFile(path, expected, 0644); err != nil { + t.Fatal(err) + } + c.removeAbsent(path) + for parent := filepath.Dir(path); parent != c.f.base.Home; parent = filepath.Dir(parent) { + c.acceptApprovalParent(t, parent) + } + c.approvals[r.Scope] = ids + delete(c.priorApproval, r.Scope) + c.proof(t, stage, map[string]any{"scope": r.Scope, "path": path, "body": c.stream(t, body), "fingerprints": ids, "probe_bytes": len(c.probe), "probe_sha256": clineAttemptSHA(c.probe)}) +} + +func (c *cursorMCPProjectCycle) acceptApprovalParent(t *testing.T, path string) { + t.Helper() + info, err := os.Lstat(path) + if err != nil || !info.IsDir() { + t.Fatal("Cursor approval parent is not ordinary") + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != uint32(os.Getuid()) || stat.Gid != uint32(os.Getgid()) { + t.Fatal("Cursor approval parent owner differs") + } + if old, exists := c.f.approvalParents[path]; exists { + if !cursorCycleDirectorySame(old, info) { + t.Fatal("Cursor existing approval parent changed") + } + } else { + if info.Mode() != os.ModeDir|0755 { + t.Fatal("Cursor new native approval parent mode differs") + } + c.f.approvalParents[path] = info + } +} diff --git a/repotests/agentplugins_cursor_cycle_commands_test.go b/repotests/agentplugins_cursor_cycle_commands_test.go new file mode 100644 index 000000000..ee680a398 --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_commands_test.go @@ -0,0 +1,159 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + "time" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/ports" +) + +type cursorCycleStep struct{ label, scope, verb string } + +var cursorCycleSteps = []cursorCycleStep{ + {"git-a", "a", "git"}, {"git-b", "b", "git"}, {"version", "outside", "version"}, + {"empty-a", "a", "list"}, {"empty-b", "b", "list"}, {"empty-outside", "outside", "list"}, + {"add-a", "a", "add"}, {"approve-a-one", "a", "enable"}, {"installed-a", "a", "list-tools"}, + {"add-b", "b", "add"}, {"approve-b-one", "b", "enable"}, {"installed-b", "b", "list-tools"}, {"installed-outside", "outside", "list"}, + {"repeat-add-a", "a", "add"}, {"unchanged-update-a", "a", "update"}, {"intact-repair-a", "a", "repair"}, + {"stable-a", "a", "list-tools"}, {"stable-b", "b", "list-tools"}, + {"update-a", "a", "update"}, {"approve-a-two", "a", "enable"}, {"updated-a", "a", "list-tools"}, {"updated-b", "b", "list-tools"}, {"updated-outside", "outside", "list"}, + {"damaged-a", "a", "list"}, {"missing-repair-a", "a", "repair"}, {"repaired-a", "a", "list-tools"}, + {"remove-a", "a", "remove"}, {"removed-a", "a", "list"}, {"remaining-b", "b", "list-tools"}, {"removed-outside", "outside", "list"}, + {"repeat-remove-a", "a", "remove"}, {"remove-b", "b", "remove"}, + {"final-a", "a", "list"}, {"final-b", "b", "list"}, {"final-outside", "outside", "list"}, +} + +func (c *cursorMCPProjectCycle) commandAt(index int) cursorProjectCommand { + step := cursorCycleSteps[index] + f, cwd := c.f, c.f.outside + r := c.recipes[step.scope] + if r != nil { + cwd = r.Project + } + var argv []string + switch step.verb { + case "git": + argv = []string{f.git, "init", "--quiet", "--initial-branch=main"} + case "version": + argv = []string{f.client, "--version"} + case "list": + argv = []string{f.client, "mcp", "list"} + case "enable", "list-tools": + argv = []string{f.client, "mcp", step.verb, "uapcursora"} + default: + selector := r.Installation.InstallationID + if step.verb == "add" { + selector = r.Source + } + argv = []string{f.installer, step.verb, selector} + if step.verb == "add" { + argv = append(argv, "--no-save") + } + argv = append(argv, "--scope", "project", "--project-root", r.Project, "--target", "cursor", "--format", "json") + } + return cursorProjectCommand{step.label, cwd, argv} +} + +func (c *cursorMCPProjectCycle) admit(command cursorProjectCommand) error { + if len(c.completed) >= len(cursorCycleSteps) || !reflect.DeepEqual(command, c.commandAt(len(c.completed))) { + return fmt.Errorf("Cursor exact cycle command/order differs") + } + return nil +} + +func (c *cursorMCPProjectCycle) capture(t *testing.T, command cursorProjectCommand, result ports.CommandResult, receipt map[string]any, start, end time.Time) { + t.Helper() + value := map[string]any{} + for key, item := range receipt { + value[key] = item + } + value["started_at"], value["ended_at"] = start.UTC().Format(time.RFC3339Nano), end.UTC().Format(time.RFC3339Nano) + value["stdout"], value["stderr"] = c.stream(t, result.Stdout), c.stream(t, result.Stderr) + c.append(t, cursorCycleMembers[0], command.label, value) + c.completed = append(c.completed, command.label) + step := cursorCycleSteps[len(c.completed)-1] + c.observeProbe(t, step, start, end) + if step.verb == "enable" && result.ExitCode == 0 && receipt["error"] == false && receipt["deadline"] == false { + c.acceptApproval(t, c.recipes[step.scope], command.label) + } +} + +func (c *cursorMCPProjectCycle) allowedExit(command cursorProjectCommand, result ports.CommandResult) bool { + return command.label == "repeat-remove-a" && result.ExitCode == 1 && len(result.Stdout) == 0 && string(result.Stderr) == "agentplugins: plugin is not installed for target \"cursor\" in project scope; no target was changed\n" +} + +func cursorCycleMutates(label string) bool { + switch label { + case "add-a", "add-b", "intact-repair-a", "update-a", "missing-repair-a", "remove-a", "remove-b": + return true + default: + return false + } +} + +func (c *cursorMCPProjectCycle) run(t *testing.T, index int) { + t.Helper() + step := cursorCycleSteps[index] + if step.label == "update-a" { + c.revisedA(t) + } + if step.label == "damaged-a" { + c.missingA(t) + } + c.check(t) + r := c.recipes[step.scope] + if cursorCycleMutates(step.label) { + c.openSelected(t, r) + } + if step.verb == "enable" { + c.openApproval(t, r) + } + c.stage = step.label + c.f.evidence["cycle_stage"] = step.label + command := c.commandAt(index) + result := c.f.command(t, command) + if step.verb == "add" || step.verb == "update" || step.verb == "repair" || step.verb == "remove" { + c.publicProof(t, r, step, result) + } else { + if len(result.Stderr) != 0 { + t.Fatal("Cursor cycle native stderr differs") + } + switch step.verb { + case "version": + if string(result.Stdout) != cursorProjectVersion+"\n" { + t.Fatal("Cursor cycle exact version differs") + } + case "list": + if string(result.Stdout) != "No MCP servers configured (expected in .cursor/mcp.json or ~/.cursor/mcp.json)\n" { + t.Fatal("Cursor cycle exact empty native catalog differs") + } + case "list-tools": + if string(result.Stdout) != "Tools for uapcursora (1):\n- inspect_runtime (marker, nonce, operation)\n" { + t.Fatal("Cursor cycle exact owned tools differ") + } + case "enable": + if string(result.Stdout) != "✓ Enabled and approved MCP server: uapcursora\n" { + t.Fatal("Cursor cycle exact native approval differs") + } + case "git": + if len(result.Stdout) != 0 { + t.Fatal("Cursor cycle Git setup output differs") + } + } + } + c.check(t) +} + +func cursorCyclePublicDocument(raw []byte) (map[string]any, error) { + value, err := projectNativeRuntimeJSON(raw) + object, ok := value.(map[string]any) + if err != nil || !ok || nativeValidateInstallerJSON(raw) != nil || projectNativeRuntimeKeys(object, "schema_version command result data", "") != nil || object["schema_version"] != json.Number("1") || object["result"] != "success" { + return nil, fmt.Errorf("Cursor exact public result envelope differs") + } + return object, nil +} diff --git a/repotests/agentplugins_cursor_cycle_contract_test.go b/repotests/agentplugins_cursor_cycle_contract_test.go new file mode 100644 index 000000000..6ba00f760 --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_contract_test.go @@ -0,0 +1,203 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func TestCursorCycleLiteralCommands(t *testing.T) { + f := &cursorProjectAttempt{base: &nativeFixture{Project: "/owned/A", Package: "/owned/sourceA"}, other: "/owned/B", outside: "/owned/outside", installer: "/held/uap", client: "/held/cursor-agent", git: "/held/git"} + c := &cursorMCPProjectCycle{f: f, recipes: map[string]*cursorCycleRecipe{"a": {Scope: "a", Project: "/owned/A", Source: "/owned/sourceA", Installation: domain.Installation{InstallationID: "held-a"}}, "b": {Scope: "b", Project: "/owned/B", Source: "/owned/sourceB", Installation: domain.Installation{InstallationID: "held-b"}}}} + // Independent approved table: no production table is used to author expectations. + want := strings.Fields("git-a:a:git git-b:b:git version:outside:version empty-a:a:list empty-b:b:list empty-outside:outside:list add-a:a:add approve-a-one:a:enable installed-a:a:list-tools add-b:b:add approve-b-one:b:enable installed-b:b:list-tools installed-outside:outside:list repeat-add-a:a:add unchanged-update-a:a:update intact-repair-a:a:repair stable-a:a:list-tools stable-b:b:list-tools update-a:a:update approve-a-two:a:enable updated-a:a:list-tools updated-b:b:list-tools updated-outside:outside:list damaged-a:a:list missing-repair-a:a:repair repaired-a:a:list-tools remove-a:a:remove removed-a:a:list remaining-b:b:list-tools removed-outside:outside:list repeat-remove-a:a:remove remove-b:b:remove final-a:a:list final-b:b:list final-outside:outside:list") + if len(cursorCycleSteps) != len(want) { + t.Fatal("approved stage closure changed") + } + for index, literal := range want { + parts := strings.Split(literal, ":") + step := cursorCycleStep{parts[0], parts[1], parts[2]} + if cursorCycleSteps[index] != step { + t.Fatal("approved stage changed", index) + } + cwd := "/owned/outside" + if step.scope != "outside" { + cwd = "/owned/" + strings.ToUpper(step.scope) + } + var argv []string + switch step.verb { + case "git": + argv = []string{"/held/git", "init", "--quiet", "--initial-branch=main"} + case "version": + argv = []string{"/held/cursor-agent", "--version"} + case "list": + argv = []string{"/held/cursor-agent", "mcp", "list"} + case "enable", "list-tools": + argv = []string{"/held/cursor-agent", "mcp", step.verb, "uapcursora"} + default: + selector := "held-" + step.scope + if step.verb == "add" { + selector = "/owned/source" + strings.ToUpper(step.scope) + } + argv = []string{"/held/uap", step.verb, selector} + if step.verb == "add" { + argv = append(argv, "--no-save") + } + argv = append(argv, "--scope", "project", "--project-root", cwd, "--target", "cursor", "--format", "json") + } + command := cursorProjectCommand{step.label, cwd, argv} + if !reflect.DeepEqual(c.commandAt(index), command) { + t.Fatal("approved argv/cwd changed", step.label) + } + c.completed = append(c.completed, step.label) + } +} + +func cursorCycleProbeContract(t *testing.T, methods []string, pid int) []byte { + t.Helper() + var raw []byte + for index, method := range methods { + body, err := json.Marshal(map[string]any{"method": method, "pid": pid, "session": strconv.Itoa(pid) + "-100", "timestamp": time.Unix(100, int64(index+1)).UTC().Format(time.RFC3339Nano), "nonce": "held-nonce", "facts": nil}) + if err != nil { + t.Fatal(err) + } + raw = append(raw, append(body, '\n')...) + } + return raw +} + +func TestCursorCycleProbeSessions(t *testing.T) { + start, end := time.Unix(100, 0), time.Unix(101, 0) + for _, methods := range [][]string{{"initialize", "tools/list"}, {"initialize", "tools/list", "tools/list", "tools/list"}} { + raw := cursorCycleProbeContract(t, methods, 7) + if ids, err := cursorCycleProbeDelta(raw, "held-nonce", start, end, map[string]bool{}); err != nil || !reflect.DeepEqual(ids, []string{"7-100"}) { + t.Fatal("valid fresh discovery refused", err) + } + } + for _, methods := range [][]string{{}, {"initialize"}, {"tools/list"}, {"initialize", "initialize", "tools/list"}, {"initialize", "tools/call"}, {"initialize", "tools/list", "tools/call"}} { + if _, err := cursorCycleProbeDelta(cursorCycleProbeContract(t, methods, 7), "held-nonce", start, end, nil); err == nil { + t.Fatal("invalid MCP sequence admitted", methods) + } + } + raw := cursorCycleProbeContract(t, []string{"initialize", "tools/list"}, 7) + for _, hostile := range [][]byte{bytes.ReplaceAll(raw, []byte("held-nonce"), []byte("foreign")), bytes.ReplaceAll(raw, []byte("7-100"), []byte("7-0")), bytes.ReplaceAll(raw, []byte("\"facts\":null"), []byte("\"facts\":{}")), bytes.ReplaceAll(raw, []byte("\"pid\":7"), []byte("\"pid\":8")), bytes.ReplaceAll(raw, []byte("\"method\":"), []byte("\"unknown\":0,\"method\":")), raw[:len(raw)-1]} { + if _, err := cursorCycleProbeDelta(hostile, "held-nonce", start, end, nil); err == nil { + t.Fatal("hostile probe admitted") + } + } + if _, err := cursorCycleProbeDelta(raw, "held-nonce", start, end, map[string]bool{"7-100": true}); err == nil { + t.Fatal("old session admitted") + } +} + +func TestCursorCycleHeldFileCannotAdoptMutation(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "held") + projectNativeRuntimeWrite(t, path, []byte("original"), 0600) + f := &cursorProjectAttempt{ctx: context.Background(), held: map[string]cursorProjectHeld{}} + c := &cursorMCPProjectCycle{f: f, dirs: map[string]os.FileInfo{}, trees: map[string]map[string][]byte{}} + if err := c.acceptFile(path, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("replaced"), 0600); err != nil { + t.Fatal(err) + } + if c.acceptFile(path, []byte("replaced"), 0600) == nil || c.checkError() == nil { + t.Fatal("new bytes renewed original authority") + } +} + +func TestCursorCycleEmptyStreamHasCanonicalBase64Body(t *testing.T) { + for _, input := range [][]byte{nil, {}} { + value, err := cursorCycleStream(input) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + object, err := projectNativeRuntimeJSON(raw) + if err != nil || !reflect.DeepEqual(object, map[string]any{"bytes": json.Number("0"), "sha256": clineAttemptSHA(nil), "body": ""}) { + t.Fatal("empty process channel is not canonical base64", string(raw), err) + } + } +} + +func TestCursorCycleRemovalRequiresExactDeactivation(t *testing.T) { + r := &cursorCycleRecipe{Installation: domain.Installation{InstallationID: "held-installation"}} + step := cursorCycleStep{"remove-a", "a", "remove"} + deactivation := map[string]any{"activation": "not_required", "artifact_removal_allowed": true, "external_removal_complete": true} + outcome := map[string]any{"installation_id": "held-installation", "plugin": "native-proof", "client_id": "cursor", "requires_confirmation": false, "mutated": true, "deactivation": deactivation, "affected_surfaces": []any{"cursor"}, "group_phase": "external_completed"} + output := map[string]any{"result": outcome} + if err := cursorCyclePublicOutcome(output, step, r); err != nil { + t.Fatal(err) + } + for key, bad := range map[string]any{"activation": "active", "external_removal_complete": false, "artifact_removal_allowed": false} { + original := deactivation[key] + deactivation[key] = bad + if cursorCyclePublicOutcome(output, step, r) == nil { + t.Fatal("invalid native removal was accepted", key) + } + deactivation[key] = original + } +} + +func TestCursorCyclePublicOutcomeRequiresMeasuredStageShape(t *testing.T) { + r := &cursorCycleRecipe{Installation: domain.Installation{InstallationID: "held-installation"}, Data: domain.DataReceipt{PhysicalBackend: "held-physical"}} + for _, stage := range []string{"add-a", "repeat-add-a", "unchanged-update-a", "intact-repair-a", "update-a", "missing-repair-a"} { + t.Run(stage, func(t *testing.T) { + verb := "add" + if strings.Contains(stage, "update") { + verb = "update" + } else if strings.Contains(stage, "repair") { + verb = "repair" + } + step := cursorCycleStep{stage, "a", verb} + plan := map[string]any{"client_id": "cursor", "scope": "project", "status": "ready", "package_mode": "native", "physical_artifact_id": "held-physical", "activation": "prepared", "authentication": "not_checked", "policy": "allowed", "verification": "package_validated", "components": []any{map[string]any{"kind": "mcp_server", "name": "uapcursora", "support": "native"}}} + activation := map[string]any{"activation": "prepared", "authentication": "not_checked", "policy": "allowed", "verification": "installation_verified"} + outcome := map[string]any{"installation_id": "held-installation", "plan": plan, "activation": activation, "requires_confirmation": false, "mutated": stage != "repeat-add-a" && stage != "unchanged-update-a"} + if stage != "repeat-add-a" { + outcome["group_phase"] = "external_completed" + } + if stage == "repeat-add-a" || stage == "unchanged-update-a" { + outcome["no_change"] = true + } + output := map[string]any{"result": outcome} + if err := cursorCyclePublicOutcome(output, step, r); err != nil { + t.Fatal("measured stage shape refused", err) + } + for _, field := range []string{"no_change", "group_phase"} { + before, present := outcome[field] + if present { + delete(outcome, field) + } else { + outcome[field] = true + } + if cursorCyclePublicOutcome(output, step, r) == nil { + t.Fatal("cross-stage or missing field admitted", field) + } + if present { + outcome[field] = false + if cursorCyclePublicOutcome(output, step, r) == nil { + t.Fatal("incorrect measured field value admitted", field) + } + outcome[field] = before + } else { + delete(outcome, field) + } + } + }) + } +} diff --git a/repotests/agentplugins_cursor_cycle_finish_test.go b/repotests/agentplugins_cursor_cycle_finish_test.go new file mode 100644 index 000000000..0553f153c --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_finish_test.go @@ -0,0 +1,51 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "bytes" + "os" + "reflect" + "testing" +) + +var cursorCycleProofStages = []string{"source-a-1.0.0", "source-b-1.0.0", "original-controls", "add-a", "approve-a-one", "add-b", "approve-b-one", "repeat-add-a", "unchanged-update-a", "intact-repair-a", "source-a-1.1.0", "update-a", "approve-a-two", "missing-native-a", "missing-repair-a", "remove-a", "repeat-remove-a", "remove-b"} + +func (c *cursorMCPProjectCycle) finish(t *testing.T, events []byte) { + t.Helper() + if err := c.checkError(); err != nil { + t.Error(err) + } + if !bytes.Equal(events, c.probe) { + t.Error("Cursor final probe prefix differs") + } + want := make([]string, len(cursorCycleSteps)) + for i, step := range cursorCycleSteps { + want[i] = step.label + } + complete := c.complete && !t.Failed() && reflect.DeepEqual(c.completed, want) && reflect.DeepEqual(c.proofs, cursorCycleProofStages) && len(c.sessions) != 0 && c.recipes["a"].Removed && c.recipes["b"].Removed + c.f.evidence["cycle_command_stages"], c.f.evidence["cycle_proof_stages"] = c.completed, c.proofs + c.f.evidence["cycle_observed_complete"], c.f.evidence["runtime_attempt_complete"] = complete, complete + c.f.evidence["cycle_probe"] = map[string]any{"bytes": len(events), "sha256": clineAttemptSHA(events), "sessions": len(c.sessions)} + c.f.evidence["qualification_scope"] = "MCP-only: two project installations, fresh initialize/tools-list sessions; no tools-call, skills, runtime cwd/argv facts, vendor authentication or model" + flags := map[string]bool{"project-lifecycle": complete, "native-restart-removal": complete, "mcp-initialize": complete, "mcp-tools-list": complete, "mcp-tools-call": false} + c.f.evidence["qualification"] = flags + for _, member := range cursorCycleMembers { + c.f.keep(t, member, c.aggregates[member]) + } +} + +func TestAgentpluginsCursorNativeProjectCompleteMCPCycle(t *testing.T) { + if os.Getenv("AGENTPLUGINS_CURSOR_PROJECT_RUNTIME_ATTEMPT") != "1" { + t.Skip("opt-in genuine hosted Cursor complete MCP project cycle") + } + f := newCursorProjectAttempt(t) + defer f.finish(t) + c := newCursorMCPProjectCycle(t, f) + c.prepare(t) + for index := range cursorCycleSteps { + c.run(t, index) + } + c.check(t) + c.complete = true +} diff --git a/repotests/agentplugins_cursor_cycle_fixture_test.go b/repotests/agentplugins_cursor_cycle_fixture_test.go new file mode 100644 index 000000000..6c0ac5c53 --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_fixture_test.go @@ -0,0 +1,128 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "unicode/utf8" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +const cursorCycleMode = "cursor-complete-mcp-cycle" +const cursorCycleName = "native-proof" + +var cursorCycleMembers = []string{"cursor-cycle-commands.jsonl", "cursor-cycle-proofs.jsonl", "cursor-cycle-probe.jsonl"} + +type cursorCycleRecipe struct { + Scope, Project, Source, Version, Nonce, Revision, Digest string + Authored map[string][]byte + Installation domain.Installation + Binding domain.ClientBinding + Data domain.DataReceipt + Active, Config string + Removed bool +} + +type cursorMCPProjectCycle struct { + f *cursorProjectAttempt + recipes map[string]*cursorCycleRecipe + aggregates map[string][]byte + completed []string + proofs []string + approvals map[string][]string + priorApproval map[string]cursorProjectHeld + probe []byte + probeInfo os.FileInfo + sessions map[string]bool + trees map[string]map[string][]byte + dirs map[string]os.FileInfo + installerBody, probeBody []byte + stage string + complete bool + revising bool +} + +func newCursorMCPProjectCycle(t *testing.T, f *cursorProjectAttempt) *cursorMCPProjectCycle { + t.Helper() + c := &cursorMCPProjectCycle{f: f, recipes: map[string]*cursorCycleRecipe{}, aggregates: map[string][]byte{}, approvals: map[string][]string{}, priorApproval: map[string]cursorProjectHeld{}, sessions: map[string]bool{}, trees: map[string]map[string][]byte{}, dirs: map[string]os.FileInfo{}, completed: []string{}, proofs: []string{}} + f.cycle = c + f.evidence["capture_mode"] = cursorCycleMode + // The new recipe deliberately authors an empty original User document. + // Keep the earlier failed-only constructor and its receipts unchanged. + delete(f.evidence, "other_source_absent") + delete(f.evidence, "approval_controls_initially_absent") + return c +} + +func cursorCycleStream(raw []byte) (clineCycleBytes, error) { + marker := []byte("uap-cursor-private-") + budget := clineCyclePrivacyBudget{text: 4 * len(raw), nodes: 16384} + if len(raw) > 1<<20 || !utf8.Valid(raw) || bytes.Contains(raw, marker) || clineCycleCredentialJSON(raw, marker, 0, &budget) { + return clineCycleBytes{}, fmt.Errorf("Cursor cycle private/unbounded stream refused") + } + return clineCycleBytes{Bytes: len(raw), SHA256: clineAttemptSHA(raw), Body: append([]byte{}, raw...)}, nil +} + +func (c *cursorMCPProjectCycle) stream(t *testing.T, raw []byte) clineCycleBytes { + t.Helper() + value, err := cursorCycleStream(raw) + if err != nil { + t.Fatal(err) + } + return value +} + +func (c *cursorMCPProjectCycle) append(t *testing.T, member, stage string, value any) { + t.Helper() + known := false + for _, name := range cursorCycleMembers { + known = known || name == member + } + raw, err := json.Marshal(map[string]any{"stage": stage, "value": value}) + if err != nil || !known || stage == "" { + t.Fatal("Cursor cycle closed aggregate/stage") + } + raw = append(raw, '\n') + total := len(raw) + for _, body := range c.aggregates { + total += len(body) + } + for _, body := range c.f.files { + total += len(body) + } + if len(c.aggregates[member])+len(raw) > 1<<20 || total > 16<<20 { + t.Fatal("Cursor cycle encoded aggregate bound") + } + c.aggregates[member] = append(c.aggregates[member], raw...) +} + +func (c *cursorMCPProjectCycle) forget(path string) { + delete(c.f.held, path) + c.removeAbsent(path) +} + +func (c *cursorMCPProjectCycle) removeAbsent(path string) { + for i := 0; i < len(c.f.absent); { + if c.f.absent[i] == path { + c.f.absent = append(c.f.absent[:i], c.f.absent[i+1:]...) + } else { + i++ + } + } +} + +func (c *cursorMCPProjectCycle) proof(t *testing.T, stage string, value any) { + t.Helper() + c.append(t, cursorCycleMembers[1], stage, value) + c.proofs = append(c.proofs, stage) +} + +func (c *cursorMCPProjectCycle) statePath(r *cursorCycleRecipe) string { + return filepath.Join(r.Project, ".uap", "state-v2.json") +} diff --git a/repotests/agentplugins_cursor_cycle_guards_test.go b/repotests/agentplugins_cursor_cycle_guards_test.go new file mode 100644 index 000000000..7f493eb00 --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_guards_test.go @@ -0,0 +1,176 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func (c *cursorMCPProjectCycle) check(t *testing.T) { + t.Helper() + if err := c.checkError(); err != nil { + t.Fatal(err) + } +} + +func (c *cursorMCPProjectCycle) checkError() error { + if c.f.ctx.Err() != nil { + return fmt.Errorf("Cursor original cycle deadline exhausted") + } + if err := c.f.check(); err != nil { + return err + } + for path, before := range c.dirs { + after, err := os.Lstat(path) + if err != nil || !cursorCycleDirectorySame(before, after) { + return fmt.Errorf("Cursor held directory authority changed") + } + } + for root, files := range c.trees { + if err := c.treeSet(root, files); err != nil { + return err + } + } + return nil +} + +func (c *cursorMCPProjectCycle) treeSet(root string, files map[string][]byte) error { + dirs, count := cursorCycleDirectories(files), 0 + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil || projectNativeRuntimeParents(path) != nil { + return fmt.Errorf("Cursor held tree inventory failed") + } + name, err := filepath.Rel(root, path) + name = filepath.ToSlash(name) + if err != nil || count >= len(files)+len(dirs) { + return fmt.Errorf("Cursor held tree membership changed") + } + count++ + if dirs[name] { + if !entry.IsDir() { + return fmt.Errorf("Cursor held tree directory changed") + } + return nil + } + body, ok := files[name] + if !ok || !entry.Type().IsRegular() || c.f.held[path].digest != clineAttemptSHA(body) { + return fmt.Errorf("Cursor held tree file membership changed") + } + return nil + }) + if err != nil || count != len(files)+len(dirs) { + return fmt.Errorf("Cursor held tree membership changed") + } + return nil +} + +func (c *cursorMCPProjectCycle) releasePackage(r *cursorCycleRecipe) { + if r.Active == "" { + return + } + delete(c.trees, r.Active) + for path := range c.f.held { + if strings.HasPrefix(path, r.Active+string(filepath.Separator)) { + delete(c.f.held, path) + } + } + for path := range c.dirs { + if path == r.Active || strings.HasPrefix(path, r.Active+string(filepath.Separator)) { + delete(c.dirs, path) + } + } +} + +func (c *cursorMCPProjectCycle) openSelected(t *testing.T, r *cursorCycleRecipe) { + t.Helper() + c.check(t) + c.forget(c.statePath(r)) + c.forget(r.Config) + delete(c.trees, filepath.Dir(r.Config)) + c.releasePackage(r) +} + +func cursorCycleDocument(raw []byte, expected any) error { + value, err := projectNativeRuntimeJSON(raw) + known, marshalErr := json.Marshal(expected) + if err != nil || marshalErr != nil { + return fmt.Errorf("Cursor closed document decode") + } + want, err := projectNativeRuntimeJSON(known) + if err != nil || !reflect.DeepEqual(value, want) { + return fmt.Errorf("Cursor closed document differs from independent recipe") + } + return nil +} + +func (c *cursorMCPProjectCycle) profile(t *testing.T, r *cursorCycleRecipe) []byte { + t.Helper() + raw, err := projectNativeRuntimeRead(r.Config, 1<<20) + if err != nil { + t.Fatal("Cursor selected native config missing", err) + } + servers := map[string]any{} + if !r.Removed { + servers["uapcursora"] = c.server(r) + } + if err := cursorCycleDocument(raw, map[string]any{"mcpServers": servers}); err != nil { + t.Fatal(err) + } + files := map[string][]byte{"mcp.json": raw, "foreign-owned.txt": []byte("caller-owned " + r.Scope + "\n"), "mcp.json.agentplugins.lock": {}} + c.acceptTree(t, filepath.Dir(r.Config), files, false) + return raw +} + +func (c *cursorMCPProjectCycle) dataProof(t *testing.T, r *cursorCycleRecipe, first bool) map[string]any { + t.Helper() + owner := filepath.Join(r.Data.Locator, ".agentplugins-data-owner.json") + raw, err := projectNativeRuntimeRead(owner, 1<<20) + if err != nil || cursorCycleDocument(raw, r.Data) != nil { + t.Fatal("Cursor owned data marker differs", err) + } + marker := []byte("caller-data-" + r.Scope + "-" + c.f.base.Nonce + "\n") + if first { + projectNativeRuntimeWrite(t, filepath.Join(r.Data.Locator, "caller-owned.txt"), marker, 0600) + } + c.acceptTree(t, r.Data.Locator, map[string][]byte{".agentplugins-data-owner.json": raw, "caller-owned.txt": marker}, true) + return map[string]any{"receipt": r.Data, "owner": c.stream(t, raw), "caller": c.stream(t, marker)} +} + +func (c *cursorMCPProjectCycle) missingA(t *testing.T) { + t.Helper() + c.check(t) + r := c.recipes["a"] + raw, err := projectNativeRuntimeRead(r.Config, 1<<20) + if err != nil || cursorCycleDocument(raw, map[string]any{"mcpServers": map[string]any{"uapcursora": c.server(r)}}) != nil { + t.Fatal("Cursor sole owned native-config absence fault refused", err) + } + before, err := os.Lstat(r.Config) + if held := c.f.held[r.Config]; err != nil || !os.SameFile(before, held.info) || !projectNativeRuntimeStableStat(before.Sys(), held.info.Sys()) { + t.Fatal("Cursor fault file authority changed") + } + if err := os.Remove(r.Config); err != nil { + t.Fatal(err) + } + c.forget(r.Config) + c.f.absent = append(c.f.absent, r.Config) + c.trees[filepath.Dir(r.Config)] = map[string][]byte{"foreign-owned.txt": []byte("caller-owned a\n"), "mcp.json.agentplugins.lock": {}} + c.proof(t, "missing-native-a", map[string]any{"scope": "a", "path": r.Config, "before": c.stream(t, raw), "managed_package_unchanged": true}) + c.check(t) +} + +func (c *cursorMCPProjectCycle) proofPayload(t *testing.T, r *cursorCycleRecipe) map[string]any { + t.Helper() + files := c.projected(r) + c.acceptTree(t, r.Active, files, false) + result := map[string]any{} + for name, body := range files { + result[name] = map[string]any{"bytes": len(body), "sha256": clineAttemptSHA(body), "executable": name == "bin/probe" || strings.HasSuffix(name, "/managed-stdio-v1/agentplugins")} + } + return map[string]any{"root": r.Active, "files": result, "digest": c.payloadDigest(t, files)} +} diff --git a/repotests/agentplugins_cursor_cycle_payload_test.go b/repotests/agentplugins_cursor_cycle_payload_test.go new file mode 100644 index 000000000..3d718f8ca --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_payload_test.go @@ -0,0 +1,170 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/packagedigest" +) + +func (c *cursorMCPProjectCycle) server(r *cursorCycleRecipe) map[string]any { + return map[string]any{"command": filepath.Join(r.Active, "io.github.777genius.agentplugins", "managed-stdio-v1", "agentplugins"), "args": []string{"--internal-stdio-v1", r.Active, r.Data.Locator, r.Active, "plugin", "--argv-base64-v1", "Li9iaW4vcHJvYmU=", "Y3Vyc29yLWNvbXBvbmVudA=="}, "env": map[string]string{"PLUGIN_ROOT": r.Active, "PLUGIN_DATA": r.Data.Locator, "UAP_TEST_ROOT": c.f.base.Root, "UAP_TEST_EVENTS": c.f.base.Events, "UAP_TEST_NONCE": r.Nonce, "UAP_TEST_REVISION": r.Revision}} +} + +func cursorCycleJSON(value any) []byte { + body, err := json.MarshalIndent(value, "", " ") + if err != nil { + panic("fixed Cursor cycle JSON recipe") + } + return append(body, '\n') +} + +func (c *cursorMCPProjectCycle) projected(r *cursorCycleRecipe) map[string][]byte { + metadata, _ := json.Marshal(struct { + Protocol int `json:"protocol"` + SHA string `json:"sha256"` + Version string `json:"cliVersion"` + }{1, clineAttemptSHA(c.installerBody), "0.1.0-development"}) + return map[string][]byte{"plugin.json": r.Authored["plugin.json"], "bin/probe": c.probeBody, "mcp.json": cursorCycleJSON(map[string]any{"mcpServers": map[string]any{"uapcursora": c.server(r)}}), ".cursor-plugin/plugin.json": cursorCycleJSON(map[string]any{"name": cursorCycleName, "version": r.Version, "mcpServers": "./mcp.json"}), "io.github.777genius.agentplugins/managed-stdio-v1/agentplugins": c.installerBody, "io.github.777genius.agentplugins/managed-stdio-v1/metadata.json": metadata} +} + +func cursorCycleDirectories(files map[string][]byte) map[string]bool { + result := map[string]bool{".": true} + for name := range files { + for dir := filepath.ToSlash(filepath.Dir(name)); dir != "."; dir = filepath.ToSlash(filepath.Dir(dir)) { + result[dir] = true + } + } + return result +} + +func cursorCyclePermitted(info os.FileInfo, declared os.FileMode) bool { + return info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky|os.ModeSymlink) == 0 && info.Mode().Perm()&^declared == 0 && info.Mode().Perm()&0400 != 0 && (info.Mode().Perm()&0111 != 0) == (declared&0111 != 0) +} + +func (c *cursorMCPProjectCycle) acceptFile(path string, expected []byte, declared os.FileMode) error { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || !cursorCyclePermitted(info, declared) || info.Size() != int64(len(expected)) { + return fmt.Errorf("Cursor expected ordinary file/mode/size differs") + } + s, ok := info.Sys().(*syscall.Stat_t) + if !ok || s.Uid != uint32(os.Getuid()) || s.Gid != uint32(os.Getgid()) || s.Nlink != 1 { + return fmt.Errorf("Cursor expected file owner/link differs") + } + if held, exists := c.f.held[path]; exists && (!os.SameFile(info, held.info) || !projectNativeRuntimeStableStat(info.Sys(), held.info.Sys()) || held.digest != clineAttemptSHA(expected)) { + return fmt.Errorf("Cursor original file authority cannot be renewed") + } + if err := c.f.hold(path); err != nil { + return err + } + held := c.f.held[path] + if held.digest != clineAttemptSHA(expected) || !os.SameFile(info, held.info) || !projectNativeRuntimeStableStat(info.Sys(), held.info.Sys()) { + return fmt.Errorf("Cursor validated file changed before hold") + } + return nil +} + +func (c *cursorMCPProjectCycle) acceptTree(t *testing.T, root string, files map[string][]byte, source bool) { + t.Helper() + if err := c.acceptTreeError(root, files, source); err != nil { + t.Fatal("Cursor complete expected tree differs", err) + } +} + +func (c *cursorMCPProjectCycle) acceptTreeError(root string, files map[string][]byte, source bool) error { + directories, count := cursorCycleDirectories(files), 0 + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil || projectNativeRuntimeParents(path) != nil { + return fmt.Errorf("Cursor expected tree parent differs") + } + name, err := filepath.Rel(root, path) + name = filepath.ToSlash(name) + if err != nil || count >= len(files)+len(directories) { + return fmt.Errorf("Cursor expected tree finite membership differs") + } + count++ + if directories[name] { + return c.acceptDirectory(path, name, source) + } + body, ok := files[name] + if !ok || !entry.Type().IsRegular() { + return fmt.Errorf("Cursor unknown/aliased package member") + } + mode := os.FileMode(0644) + if source { + mode = 0600 + } + if name == "bin/probe" { + mode = 0755 + if source { + mode = 0700 + } + } + if strings.HasSuffix(name, "/managed-stdio-v1/agentplugins") { + mode = 0700 + } + if strings.HasSuffix(name, "/managed-stdio-v1/metadata.json") { + mode = 0600 + } + return c.acceptFile(path, body, mode) + }) + if err != nil { + return err + } + if count != len(files)+len(directories) { + return fmt.Errorf("Cursor expected tree finite membership differs") + } + c.trees[root] = files + return nil +} + +func (c *cursorMCPProjectCycle) acceptDirectory(path, name string, source bool) error { + info, err := os.Lstat(path) + mode := os.FileMode(0755) + if source || strings.HasPrefix(name, "io.github.777genius.agentplugins") { + mode = 0700 + } + if err != nil || !info.IsDir() || !cursorCyclePermitted(info, mode) { + return fmt.Errorf("Cursor ordinary expected directory differs") + } + s, ok := info.Sys().(*syscall.Stat_t) + if !ok || s.Uid != uint32(os.Getuid()) || s.Gid != uint32(os.Getgid()) { + return fmt.Errorf("Cursor directory owner differs") + } + if before, exists := c.dirs[path]; exists && !cursorCycleDirectorySame(before, info) { + return fmt.Errorf("Cursor original directory cannot be renewed") + } + c.dirs[path] = info + return nil +} + +func cursorCycleDirectorySame(a, b os.FileInfo) bool { + x, okX := a.Sys().(*syscall.Stat_t) + y, okY := b.Sys().(*syscall.Stat_t) + return okX && okY && os.SameFile(a, b) && a.Mode() == b.Mode() && x.Uid == y.Uid && x.Gid == y.Gid +} + +func (c *cursorMCPProjectCycle) payloadDigest(t *testing.T, files map[string][]byte) string { + t.Helper() + items := []packagedigest.CapturedEntry{} + for name := range cursorCycleDirectories(files) { + if name != "." { + items = append(items, packagedigest.CapturedEntry{Path: name, Kind: "directory"}) + } + } + for name, body := range files { + items = append(items, packagedigest.CapturedEntry{Path: name, Kind: "file", Executable: name == "bin/probe" || strings.HasSuffix(name, "/managed-stdio-v1/agentplugins"), Content: body}) + } + digest, err := projectNativeRuntimeInstalledDigest(c.f.ctx, items) + if err != nil { + t.Fatal(err) + } + return digest +} diff --git a/repotests/agentplugins_cursor_cycle_probe_test.go b/repotests/agentplugins_cursor_cycle_probe_test.go new file mode 100644 index 000000000..0a33d97ce --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_probe_test.go @@ -0,0 +1,118 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +type cursorCycleProbeRow struct { + Method, Nonce, Session, Timestamp string + PID int + Facts any +} + +func (c *cursorMCPProjectCycle) beforeProbe(t *testing.T) { + t.Helper() + raw, err := projectNativeRuntimeRead(c.f.base.Events, 1<<20) + info, statErr := os.Lstat(c.f.base.Events) + if err != nil || statErr != nil || !bytes.Equal(raw, c.probe) || !os.SameFile(info, c.probeInfo) || !projectNativeRuntimeStableStat(info.Sys(), c.probeInfo.Sys()) { + t.Fatal("Cursor original probe prefix/authority changed") + } +} + +func cursorCycleProbeDelta(raw []byte, nonce string, start, end time.Time, previous map[string]bool) ([]string, error) { + if len(raw) == 0 || len(raw) > 1<<20 || raw[len(raw)-1] != '\n' || end.Before(start) || end.Sub(start) > 35*time.Second { + return nil, fmt.Errorf("Cursor probe frame/budget differs") + } + type progress struct{ pid, lists int } + sessions, pids := map[string]progress{}, map[int]string{} + last := start + for _, line := range bytes.Split(raw[:len(raw)-1], []byte{'\n'}) { + value, err := projectNativeRuntimeJSON(line) + object, ok := value.(map[string]any) + if err != nil || !ok || projectNativeRuntimeKeys(object, "method nonce pid session timestamp facts", "") != nil { + return nil, fmt.Errorf("Cursor closed probe event differs") + } + var row cursorCycleProbeRow + if json.Unmarshal(line, &row) != nil || row.Facts != nil || row.Nonce != nonce || row.PID <= 0 || row.Session == "" || previous[row.Session] { + return nil, fmt.Errorf("Cursor probe challenge/session differs") + } + parts := strings.Split(row.Session, "-") + if len(parts) != 2 || parts[0] != strconv.Itoa(row.PID) || parts[1] == "" { + return nil, fmt.Errorf("Cursor intrinsic probe identity differs") + } + serial, serialErr := strconv.ParseUint(parts[1], 10, 64) + if serialErr != nil || serial == 0 || strconv.FormatUint(serial, 10) != parts[1] { + return nil, fmt.Errorf("Cursor intrinsic probe identity differs") + } + for _, digit := range parts[1] { + if digit < '0' || digit > '9' { + return nil, fmt.Errorf("Cursor intrinsic probe identity differs") + } + } + when, err := time.Parse(time.RFC3339Nano, row.Timestamp) + if err != nil || !strings.HasSuffix(row.Timestamp, "Z") || when.Before(last) || when.After(end) { + return nil, fmt.Errorf("Cursor probe command time differs") + } + last = when + state, exists := sessions[row.Session] + if row.Method == "initialize" { + if exists || pids[row.PID] != "" { + return nil, fmt.Errorf("Cursor probe session initialized twice") + } + sessions[row.Session], pids[row.PID] = progress{pid: row.PID}, row.Session + } else if row.Method == "tools/list" && exists && state.pid == row.PID { + state.lists++ + sessions[row.Session] = state + } else { + return nil, fmt.Errorf("Cursor probe must only initialize then list tools") + } + } + ids := []string{} + for id, state := range sessions { + if state.lists == 0 { + return nil, fmt.Errorf("Cursor probe lacks tools discovery") + } + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, fmt.Errorf("Cursor command lacks fresh MCP session") + } + return ids, nil +} + +func (c *cursorMCPProjectCycle) observeProbe(t *testing.T, step cursorCycleStep, start, end time.Time) { + t.Helper() + raw, err := projectNativeRuntimeRead(c.f.base.Events, 1<<20) + info, statErr := os.Lstat(c.f.base.Events) + if err != nil || statErr != nil || !bytes.HasPrefix(raw, c.probe) || !cursorCycleDirectorySame(c.probeInfo, info) { + t.Fatal("Cursor probe original prefix/identity changed") + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Nlink != 1 || info.Mode() != 0600 || info.Size() != int64(len(raw)) { + t.Fatal("Cursor probe file authority differs") + } + delta := raw[len(c.probe):] + c.append(t, cursorCycleMembers[2], step.label, map[string]any{"before_bytes": len(c.probe), "before_sha256": clineAttemptSHA(c.probe), "after_bytes": len(raw), "after_sha256": clineAttemptSHA(raw), "delta": c.stream(t, delta)}) + if step.verb == "list-tools" { + ids, err := cursorCycleProbeDelta(delta, c.recipes[step.scope].Nonce, start, end, c.sessions) + if err != nil { + t.Fatal(err) + } + for _, id := range ids { + c.sessions[id] = true + } + } else if len(delta) != 0 || !projectNativeRuntimeStableStat(c.probeInfo.Sys(), info.Sys()) { + t.Fatal("Cursor non-tools command changed probe events") + } + c.probe, c.probeInfo = bytes.Clone(raw), info +} diff --git a/repotests/agentplugins_cursor_cycle_proof_test.go b/repotests/agentplugins_cursor_cycle_proof_test.go new file mode 100644 index 000000000..118959e11 --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_proof_test.go @@ -0,0 +1,170 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func (c *cursorMCPProjectCycle) readState(t *testing.T, r *cursorCycleRecipe) (domain.StateFileV2, []byte) { + t.Helper() + raw, err := projectNativeRuntimeRead(c.statePath(r), 1<<20) + var state domain.StateFileV2 + if err != nil || json.Unmarshal(raw, &state) != nil || cursorCycleDocument(raw, state) != nil || state.SchemaVersion != 5 || len(state.Installations) != 1 || len(state.EnvironmentReceipts) != 0 { + t.Fatal("Cursor closed single-installation state differs", err) + } + i := state.Installations[0] + if !copilotReadinessID(i.InstallationID) || i.DeclaredName != cursorCycleName || i.OriginMode != domain.OriginModeDirect || i.Directory != nil || i.NeedsRebind || i.LocalChatGPTMapping != nil || len(i.InstallPreferences) != 0 { + t.Fatal("Cursor selected installation identity differs") + } + if r.Installation.InstallationID != "" && i.InstallationID != r.Installation.InstallationID { + t.Fatal("Cursor installation identity changed") + } + for _, stamp := range []string{i.CreatedAt, i.UpdatedAt} { + value, err := time.Parse(time.RFC3339Nano, stamp) + if err != nil || value.Before(c.f.started) || value.After(time.Now()) { + t.Fatal("Cursor installation time differs") + } + } + source := domain.SourceBinding{SourceBindingID: "src_" + clineAttemptSHA([]byte(r.Source + "\x00\x00"))[:24], RequestedSource: r.Source, CanonicalSource: r.Source, TreeDigest: r.Digest} + pack := domain.PackageBinding{LoaderKind: domain.LoaderKindAgentPlugins, FormatID: domain.FormatIDAgentPluginsV1, SchemaURI: domain.PluginSchemaV1, DeclaredName: cursorCycleName, Version: r.Version, ManifestDigest: "sha256:" + clineAttemptSHA(r.Authored["plugin.json"]), Inventory: domain.ComponentInventory{MCPPresent: true, MCPEnabled: true, MCPServers: []string{"uapcursora"}}} + if !reflect.DeepEqual(i.Source, source) || !reflect.DeepEqual(i.Package, pack) { + t.Fatal("Cursor independent source/package recipe differs") + } + return state, raw +} + +func (c *cursorMCPProjectCycle) currentData(t *testing.T, r *cursorCycleRecipe, i domain.Installation) { + t.Helper() + physical := cursorCycleName + "-" + clineAttemptSHA([]byte(i.InstallationID))[:12] + id := "data_" + clineAttemptSHA([]byte(strings.Join([]string{i.InstallationID, physical, "project"}, "\x00")))[:24] + d := i.DataReceipts[id] + path := filepath.Join(r.Project, ".uap", "plugin-data", physical) + want := "sha256:" + clineAttemptSHA([]byte("agentplugins-plugin-data-v1\x00"+id+"\x00"+path)) + if len(i.DataReceipts) != 1 || d.DataReceiptID != id || d.PhysicalBackend != physical || d.Scope != "project" || d.Locator != path || d.OwnershipDigest != want || d.State != domain.DataReceiptOwned { + t.Fatal("Cursor persistent data authority differs") + } + if r.Data.DataReceiptID != "" && !reflect.DeepEqual(r.Data, d) { + t.Fatal("Cursor persistent data receipt changed") + } + for scope, other := range c.recipes { + if scope != r.Scope && other.Installation.InstallationID != "" && (other.Installation.InstallationID == i.InstallationID || other.Data.DataReceiptID == id || other.Data.PhysicalBackend == physical) { + t.Fatal("Cursor A/B physical/data authority overlaps") + } + } + r.Data = d + r.Active = filepath.Join(r.Project, ".uap", "managed", "clients", "cursor", physical) +} + +func (c *cursorMCPProjectCycle) binding(t *testing.T, r *cursorCycleRecipe, i domain.Installation) domain.ClientBinding { + t.Helper() + id := "client_" + clineAttemptSHA([]byte(strings.Join([]string{i.InstallationID, "cursor", "project", r.Active}, "\x00")))[:24] + b := i.Clients[id] + if len(i.Clients) != 1 || i.DataRetained || b.ClientBindingID != id || b.ClientID != "cursor" || b.Scope != "project" || b.TargetLocator != r.Active || b.PhysicalArtifact != r.Data.PhysicalBackend || b.DataReceiptID != r.Data.DataReceiptID || b.NativeProfileRoot != filepath.Dir(r.Config) { + t.Fatal("Cursor selected project binding differs") + } + if b.Materialization != domain.MaterializationMaterialized || b.Activation != domain.ActivationPrepared || b.Authentication != domain.AuthenticationNotChecked || b.Policy != domain.PolicyAllowed || b.Verification != domain.VerificationInstalled || !reflect.DeepEqual(b.AffectedSurfaces, []string{"cursor"}) || b.NativeActivationAttempt != "" || b.PendingNativeIntent != nil || !b.SelectedDelivery.IsZero() || b.InstallIntent != "" || b.ProfileAuthority != nil || b.ProfileNamespace != "" || b.LocalEntryObservation != nil { + t.Fatal("Cursor conservative installed state was upgraded") + } + revision := &domain.ClientPackageRevision{Version: r.Version, TreeDigest: r.Digest, ManifestDigest: "sha256:" + clineAttemptSHA(r.Authored["plugin.json"])} + server, _ := json.Marshal(c.server(r)) + nativeDigest := "sha256:" + clineAttemptSHA(append([]byte("agentplugins-native-mcp-v1\x00mcpServers\x00uapcursora\x00"), server...)) + objects := []domain.NativeObjectOwnership{{ObjectID: "package:cursor:" + r.Data.PhysicalBackend, Kind: "managed_package_directory", LogicalName: cursorCycleName, Path: r.Active, ManagedDigest: c.payloadDigest(t, c.projected(r)), ProtectionClass: "managed"}, {ObjectID: "cursor_project_mcp_server:uapcursora", Kind: "cursor_project_mcp_server", LogicalName: "uapcursora", Path: r.Config, ManagedDigest: nativeDigest, ProtectionClass: "managed"}} + if !reflect.DeepEqual(b.PackageRevision, revision) || !reflect.DeepEqual(b.NativeObjects, objects) { + t.Fatal("Cursor expected native ownership differs") + } + return b +} + +func cursorCycleReceipt(r *cursorCycleRecipe, row domain.MutationReceipt, group string, sequence int, kind, before, after string) error { + if len(group) != 35 || !strings.HasPrefix(group, "op-") { + return fmt.Errorf("Cursor operation group differs") + } + for _, b := range group[3:] { + if !strings.ContainsRune("0123456789abcdef", b) { + return fmt.Errorf("Cursor operation group differs") + } + } + op := group + "-001" + base := filepath.Dir(r.Active) + want := domain.MutationReceipt{OperationID: op, OperationGroupID: group, Sequence: sequence, MutationType: kind, ClientBindingID: r.Binding.ClientBindingID, ActivePath: r.Active, BackupPath: filepath.Join(base, ".agentplugins-backup-"+clineAttemptSHA([]byte(op))[:16]), BeforeDigest: before, AfterDigest: after, Phase: "committed"} + if kind == "directory_swap" { + want.StagingPath = filepath.Join(base, ".agentplugins-staging-"+clineAttemptSHA([]byte(op))[:16]) + } + if !reflect.DeepEqual(row, want) { + return fmt.Errorf("Cursor exact receipt history differs") + } + return nil +} + +func (c *cursorMCPProjectCycle) swapProof(t *testing.T, r *cursorCycleRecipe, i domain.Installation, b domain.ClientBinding, mutated bool) { + t.Helper() + old := r.Binding + if !mutated { + if !reflect.DeepEqual(old, b) { + t.Fatal("Cursor no-op changed its binding") + } + return + } + if len(b.Receipts) != len(old.Receipts)+1 || !reflect.DeepEqual(b.Receipts[:len(old.Receipts)], old.Receipts) && len(old.Receipts) != 0 { + t.Fatal("Cursor receipt prefix changed") + } + before := "" + if len(old.NativeObjects) != 0 { + before = old.NativeObjects[0].ManagedDigest + } + r.Binding = b + if err := cursorCycleReceipt(r, b.Receipts[len(b.Receipts)-1], i.OperationGroupID, len(b.Receipts), "directory_swap", before, b.NativeObjects[0].ManagedDigest); err != nil { + t.Fatal(err) + } +} + +func (c *cursorMCPProjectCycle) installedProof(t *testing.T, r *cursorCycleRecipe, stage string, mutated bool) { + t.Helper() + state, raw := c.readState(t, r) + i := state.Installations[0] + first := r.Installation.InstallationID == "" + c.currentData(t, r, i) + b := c.binding(t, r, i) + c.swapProof(t, r, i, b, mutated) + if len(state.TransactionReceipts) != 0 { + t.Fatal("Cursor live package has state-level removal receipts") + } + r.Installation, r.Binding = i, b + payload, config := c.proofPayload(t, r), c.profile(t, r) + data := c.dataProof(t, r, first) + if err := c.acceptFile(c.statePath(r), raw, 0600); err != nil { + t.Fatal(err) + } + c.proof(t, stage, map[string]any{"scope": r.Scope, "version": r.Version, "nonce": r.Nonce, "revision": r.Revision, "source_digest": r.Digest, "removed": false, "state": c.stream(t, raw), "native_config": c.stream(t, config), "payload": payload, "data": data}) +} + +func (c *cursorMCPProjectCycle) removedProof(t *testing.T, r *cursorCycleRecipe, stage string) { + t.Helper() + state, raw := c.readState(t, r) + i := state.Installations[0] + c.currentData(t, r, i) + if !i.DataRetained || len(i.Clients) != 0 || len(state.TransactionReceipts) != 1 || cursorCycleReceipt(r, state.TransactionReceipts[0], i.OperationGroupID, len(r.Binding.Receipts)+1, "directory_remove", r.Binding.NativeObjects[0].ManagedDigest, "") != nil { + t.Fatal("Cursor exact removal/retained-data state differs") + } + if _, err := os.Lstat(r.Active); !os.IsNotExist(err) { + t.Fatal("Cursor removed package still present") + } + c.forget(r.Active) + c.f.absent = append(c.f.absent, r.Active) + r.Removed, r.Installation = true, i + config, data := c.profile(t, r), c.dataProof(t, r, false) + if err := c.acceptFile(c.statePath(r), raw, 0600); err != nil { + t.Fatal(err) + } + c.proof(t, stage, map[string]any{"scope": r.Scope, "version": r.Version, "nonce": r.Nonce, "revision": r.Revision, "source_digest": r.Digest, "removed": true, "state": c.stream(t, raw), "native_config": c.stream(t, config), "data": data}) +} diff --git a/repotests/agentplugins_cursor_cycle_public_test.go b/repotests/agentplugins_cursor_cycle_public_test.go new file mode 100644 index 000000000..e09154b56 --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_public_test.go @@ -0,0 +1,160 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/ports" +) + +func cursorCyclePublicOutput(document map[string]any, step cursorCycleStep, r *cursorCycleRecipe) (map[string]any, error) { + refused := fmt.Errorf("Cursor exact public cycle DTO differs") + if r == nil || document["command"] != step.verb { + return nil, refused + } + data, ok := document["data"].(map[string]any) + if !ok { + return nil, refused + } + output := data + if step.label != "repeat-add-a" { + if projectNativeRuntimeKeys(data, "operation_id batch status succeeded failed plugin dry_run targets", "version source tree_digest manifest_digest security action_required environment plugin_data_preserved data_retained retained_data retained_data_action") != nil || data["batch"] != true || data["succeeded"] != json.Number("1") || data["failed"] != json.Number("0") || data["plugin"] != cursorCycleName || data["dry_run"] != false { + return nil, refused + } + status := "completed" + if step.verb == "remove" { + status = "data_retained" + } + if data["status"] != status { + return nil, refused + } + targets, ok := data["targets"].([]any) + if !ok || len(targets) != 1 { + return nil, refused + } + target, ok := targets[0].(map[string]any) + if !ok || projectNativeRuntimeKeys(target, "target status output", "selected next_action") != nil || target["target"] != "cursor" || target["status"] != "external_completed" { + return nil, refused + } + if step.verb == "update" && target["selected"] != true { + return nil, refused + } + output, ok = target["output"].(map[string]any) + if !ok || output["operation_id"] != data["operation_id"] { + return nil, refused + } + if cursorCycleMutates(step.label) && data["operation_id"] != r.Installation.OperationGroupID { + return nil, refused + } + if step.verb == "add" && !reflect.DeepEqual(data["environment"], map[string]any{"reason": "no_save_requested", "saved": false}) { + return nil, refused + } + if step.verb == "remove" { + retained := []any{map[string]any{"data_receipt_id": r.Data.DataReceiptID, "physical_backend_id": r.Data.PhysicalBackend, "scope": "project", "state": "owned"}} + if data["data_retained"] != true || data["plugin_data_preserved"] != true || !reflect.DeepEqual(data["retained_data"], retained) { + return nil, refused + } + } + } + optional := "operation_id next_action security" + if step.label == "repeat-add-a" { + optional = "next_action security" + } + if projectNativeRuntimeKeys(output, "plugin version source tree_digest manifest_digest dry_run result", optional) != nil { + return nil, refused + } + source := "direct local source" + if step.verb == "repair" || step.verb == "remove" { + source = "local" + } + for key, want := range map[string]any{"plugin": cursorCycleName, "version": r.Version, "source": source, "tree_digest": r.Digest, "manifest_digest": r.Installation.Package.ManifestDigest, "dry_run": false} { + if output[key] != want { + return nil, refused + } + } + return output, nil +} + +func cursorCyclePublicOutcome(output map[string]any, step cursorCycleStep, r *cursorCycleRecipe) error { + refused := fmt.Errorf("Cursor public outcome differs from held Prepared binding") + outcome, ok := output["result"].(map[string]any) + if !ok || outcome["installation_id"] != r.Installation.InstallationID || outcome["requires_confirmation"] != false || outcome["mutated"] != cursorCycleMutates(step.label) { + return refused + } + if step.verb == "remove" { + if projectNativeRuntimeKeys(outcome, "installation_id plugin client_id requires_confirmation mutated deactivation affected_surfaces group_phase", "") != nil || outcome["plugin"] != cursorCycleName || outcome["client_id"] != "cursor" || outcome["group_phase"] != "external_completed" || !reflect.DeepEqual(outcome["affected_surfaces"], []any{"cursor"}) { + return refused + } + deactivation, ok := outcome["deactivation"].(map[string]any) + if !ok || !reflect.DeepEqual(deactivation, map[string]any{"activation": "not_required", "artifact_removal_allowed": true, "external_removal_complete": true}) { + return refused + } + return nil + } + keys := "installation_id plan activation requires_confirmation mutated" + if step.label != "repeat-add-a" { + keys += " group_phase" + if outcome["group_phase"] != "external_completed" { + return refused + } + } + if step.label == "repeat-add-a" || step.label == "unchanged-update-a" { + keys += " no_change" + if outcome["no_change"] != true { + return refused + } + } + if projectNativeRuntimeKeys(outcome, keys, "") != nil { + return refused + } + plan, p := outcome["plan"].(map[string]any) + activation, a := outcome["activation"].(map[string]any) + if !p || !a || projectNativeRuntimeKeys(plan, "client_id scope status package_mode activation authentication policy verification physical_artifact_id components", "user_actions warnings") != nil || projectNativeRuntimeKeys(activation, "activation authentication policy verification", "user_actions") != nil { + return refused + } + for key, value := range map[string]any{"client_id": "cursor", "scope": "project", "status": "ready", "package_mode": "native", "physical_artifact_id": r.Data.PhysicalBackend, "activation": "prepared", "authentication": "not_checked", "policy": "allowed", "verification": "package_validated"} { + if plan[key] != value { + return refused + } + } + for key, value := range map[string]any{"activation": "prepared", "authentication": "not_checked", "policy": "allowed", "verification": "installation_verified"} { + if activation[key] != value { + return refused + } + } + if !reflect.DeepEqual(plan["components"], []any{map[string]any{"kind": "mcp_server", "name": "uapcursora", "support": "native"}}) { + return refused + } + return nil +} + +func (c *cursorMCPProjectCycle) publicProof(t *testing.T, r *cursorCycleRecipe, step cursorCycleStep, result ports.CommandResult) { + t.Helper() + if step.label == "repeat-remove-a" { + if !c.allowedExit(c.commandAt(len(c.completed)-1), result) { + t.Fatal("Cursor repeated removal differs") + } + c.removedProof(t, r, step.label) + return + } + document, err := cursorCyclePublicDocument(result.Stdout) + if err != nil || string(result.Stderr) != grokCycleProgress(step.label, cursorCycleName) { + t.Fatal("Cursor exact public JSON/progress differs", err) + } + if step.verb == "remove" { + c.removedProof(t, r, step.label) + } else { + c.installedProof(t, r, step.label, cursorCycleMutates(step.label)) + } + output, err := cursorCyclePublicOutput(document, step, r) + if err != nil { + t.Fatal(err) + } + if err := cursorCyclePublicOutcome(output, step, r); err != nil { + t.Fatal(err) + } +} diff --git a/repotests/agentplugins_cursor_cycle_source_test.go b/repotests/agentplugins_cursor_cycle_source_test.go new file mode 100644 index 000000000..6101558ce --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_source_test.go @@ -0,0 +1,171 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/packagedigest" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +func (c *cursorMCPProjectCycle) prepare(t *testing.T) { + t.Helper() + f := c.f + if err := f.check(); err != nil { + t.Fatal(err) + } + var err error + c.installerBody, err = projectNativeRuntimeRead(f.installer, 64<<20) + if err != nil || clineAttemptSHA(c.installerBody) != f.held[f.installer].digest { + t.Fatal("Cursor cycle trusted launcher bytes differ", err) + } + c.probeBody, err = projectNativeRuntimeRead(f.base.Probe, 32<<20) + if err != nil || clineAttemptSHA(c.probeBody) != f.held[f.base.Probe].digest { + t.Fatal("Cursor cycle trusted probe bytes differ", err) + } + user := filepath.Join(f.base.Home, ".cursor", "mcp.json") + c.forget(user) + for path, body := range map[string][]byte{user: []byte("{\"mcpServers\":{}}\n"), filepath.Join(f.base.Home, "foreign-owned.txt"): []byte("caller-owned Cursor control\n"), f.base.Events: {}} { + projectNativeRuntimeWrite(t, path, body, 0600) + if path != f.base.Events { + if err := c.acceptFile(path, body, 0600); err != nil { + t.Fatal(err) + } + } + } + c.probeInfo, err = os.Lstat(f.base.Events) + if err != nil { + t.Fatal(err) + } + parent, err := os.Lstat(filepath.Dir(user)) + if err != nil { + t.Fatal(err) + } + f.approvalParents = map[string]os.FileInfo{filepath.Dir(user): parent} + for _, scope := range []string{"a", "b"} { + project := f.base.Project + if scope == "b" { + project = f.other + } + source := f.base.Package + if scope == "b" { + source = filepath.Join(f.base.Root, "package-B") + } + r := &cursorCycleRecipe{Scope: scope, Project: project, Source: source, Version: "1.0.0", Config: filepath.Join(project, ".cursor", "mcp.json")} + c.recipes[scope] = r + foreign := filepath.Join(project, ".cursor", "foreign-owned.txt") + projectNativeRuntimeWrite(t, foreign, []byte("caller-owned "+scope+"\n"), 0600) + if err := c.acceptFile(foreign, []byte("caller-owned "+scope+"\n"), 0600); err != nil { + t.Fatal(err) + } + lock, err := cursorCycleWriterLock(r.Config) + if err != nil { + t.Fatal(err) + } + projectNativeRuntimeWrite(t, lock, nil, 0600) + info, err := os.Lstat(lock) + if err != nil || info.Mode().Perm() != 0600 || c.acceptFile(lock, nil, 0600) != nil { + t.Fatal("Cursor original writer lock authority differs") + } + if held := f.held[lock]; !os.SameFile(info, held.info) || !projectNativeRuntimeStableStat(info.Sys(), held.info.Sys()) { + t.Fatal("Cursor validated writer lock changed before hold") + } + c.author(t, r) + c.acceptTree(t, filepath.Dir(r.Config), map[string][]byte{"foreign-owned.txt": []byte("caller-owned " + scope + "\n"), "mcp.json.agentplugins.lock": {}}, false) + } + f.evidence["cycle_layout"] = map[string]any{"root": f.base.Root, "home": f.base.Home, "a": f.base.Project, "b": f.other, "outside": f.outside, "source_a": c.recipes["a"].Source, "source_b": c.recipes["b"].Source, "nonce": f.base.Nonce, "installer": f.installer, "client": f.client, "git": f.git, "installer_sha256": clineAttemptSHA(c.installerBody), "probe_sha256": clineAttemptSHA(c.probeBody)} + c.proof(t, "original-controls", map[string]any{"user_config": user, "user_body": c.stream(t, []byte("{\"mcpServers\":{}}\n")), "foreign_user": filepath.Join(f.base.Home, "foreign-owned.txt"), "approval_controls": append([]string(nil), f.absent[3:]...)}) +} + +func cursorCycleWriterLock(config string) (string, error) { + locks, err := nativeconfig.WriterLockPaths(nativeconfig.Paths{JSON: config}, nativeconfig.CodecMCPServers) + if err != nil || len(locks) != 1 || locks[0] != config+".agentplugins.lock" { + return "", fmt.Errorf("Cursor sole native writer lock identity differs") + } + return locks[0], nil +} + +func (c *cursorMCPProjectCycle) author(t *testing.T, r *cursorCycleRecipe) { + t.Helper() + r.Revision = r.Scope + "-" + r.Version + r.Nonce = clineAttemptSHA([]byte(c.f.base.Nonce + "\x00" + r.Revision))[:32] + manifest, _ := json.Marshal(map[string]any{"$schema": domain.PluginSchemaV1, "name": cursorCycleName, "version": r.Version}) + server := map[string]any{"type": "stdio", "command": "./bin/probe", "args": []string{"cursor-component"}, "cwd": "${PLUGIN_ROOT}", "env": map[string]string{"UAP_TEST_ROOT": c.f.base.Root, "UAP_TEST_EVENTS": c.f.base.Events, "UAP_TEST_NONCE": r.Nonce, "UAP_TEST_REVISION": r.Revision}} + mcp, _ := json.Marshal(map[string]any{"$schema": domain.MCPSchemaV1, "mcpServers": map[string]any{"uapcursora": server}}) + r.Authored = map[string][]byte{"plugin.json": append(manifest, '\n'), "mcp.json": append(mcp, '\n'), "bin/probe": c.probeBody} + entries := make([]packagedigest.CapturedEntry, 1, 1+len(r.Authored)) + entries[0] = packagedigest.CapturedEntry{Path: "bin", Kind: "directory"} + for name, body := range r.Authored { + path, mode := filepath.Join(r.Source, filepath.FromSlash(name)), os.FileMode(0600) + if name == "bin/probe" { + mode = 0700 + } + if held, exists := c.f.held[path]; exists { + if held.digest != clineAttemptSHA(body) { + if !c.revising || r.Scope != "a" || name == "bin/probe" || cursorCycleReplaceSource(path, held, body) != nil { + t.Fatal("Cursor controlled source revision refused") + } + delete(c.f.held, path) + } + } else { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatal("Cursor initial source path must be absent") + } + projectNativeRuntimeWrite(t, path, body, mode) + } + entries = append(entries, packagedigest.CapturedEntry{Path: name, Kind: "file", Executable: name == "bin/probe", Content: body}) + } + var err error + r.Digest, err = packagedigest.DigestCaptured(c.f.ctx, entries) + if err != nil { + t.Fatal(err) + } + c.acceptTree(t, r.Source, r.Authored, true) + c.proof(t, "source-"+r.Scope+"-"+r.Version, map[string]any{"scope": r.Scope, "source": r.Source, "version": r.Version, "revision": r.Revision, "nonce": r.Nonce, "digest": r.Digest, "manifest": c.stream(t, r.Authored["plugin.json"]), "mcp": c.stream(t, r.Authored["mcp.json"]), "probe_sha256": clineAttemptSHA(c.probeBody)}) +} + +func (c *cursorMCPProjectCycle) revisedA(t *testing.T) { + t.Helper() + c.check(t) + r := c.recipes["a"] + delete(c.trees, r.Source) + r.Version = "1.1.0" + c.revising = true + c.author(t, r) + c.revising = false + c.check(t) +} + +func cursorCycleReplaceSource(path string, want cursorProjectHeld, body []byte) (err error) { + if err := projectNativeRuntimeParents(path); err != nil { + return err + } + fd, err := syscall.Open(path, syscall.O_WRONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), "held-Cursor-source") + defer func() { + if file.Close() != nil { + err = fmt.Errorf("Cursor source close") + } + }() + info, err := file.Stat() + if err != nil || !os.SameFile(info, want.info) || !projectNativeRuntimeStableStat(info.Sys(), want.info.Sys()) { + return fmt.Errorf("Cursor source changed before revision") + } + if err := file.Truncate(0); err != nil { + return err + } + if n, err := file.Write(body); err != nil || n != len(body) { + return fmt.Errorf("Cursor source revision write") + } + return nil +} diff --git a/repotests/agentplugins_cursor_cycle_writer_contract_test.go b/repotests/agentplugins_cursor_cycle_writer_contract_test.go new file mode 100644 index 000000000..8f77fed70 --- /dev/null +++ b/repotests/agentplugins_cursor_cycle_writer_contract_test.go @@ -0,0 +1,97 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "context" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/adapters/nativeconfig" +) + +func cursorCycleDefaultWriterFrame(t *testing.T) (*cursorMCPProjectCycle, string, map[string][]byte) { + t.Helper() + root := t.TempDir() + config := filepath.Join(root, "mcp.json") + projectNativeRuntimeWrite(t, filepath.Join(root, "foreign-owned.txt"), []byte("caller-owned a\n"), 0600) + _, err := nativeconfig.New().Apply(nativeconfig.Request{Paths: nativeconfig.Paths{JSON: config}, Codec: nativeconfig.CodecMCPServers, Action: nativeconfig.ActionAdd, Name: "uapcursora", Server: nativeconfig.Server{Type: "remote", URL: "http://127.0.0.1:9/mcp"}}) + if err != nil { + t.Fatal(err) + } + body, err := projectNativeRuntimeRead(config, 1<<20) + if err != nil { + t.Fatal(err) + } + lock, err := cursorCycleWriterLock(config) + if err != nil || lock != filepath.Join(root, "mcp.json.agentplugins.lock") { + t.Fatal("default writer path differs", err) + } + info, err := os.Lstat(lock) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || info.Size() != 0 { + t.Fatal("default writer did not create the exact empty ordinary 0600 lock", err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != uint32(os.Getuid()) || stat.Gid != uint32(os.Getgid()) || stat.Nlink != 1 { + t.Fatal("default writer lock authority differs") + } + f := &cursorProjectAttempt{ctx: context.Background(), held: map[string]cursorProjectHeld{}} + c := &cursorMCPProjectCycle{f: f, dirs: map[string]os.FileInfo{}, trees: map[string]map[string][]byte{}} + old := map[string][]byte{"mcp.json": body, "foreign-owned.txt": []byte("caller-owned a\n")} + if c.acceptTreeError(root, old, false) == nil { + t.Fatal("original two-file frame accepted the default writer's persistent lock") + } + files := map[string][]byte{"mcp.json": body, "foreign-owned.txt": []byte("caller-owned a\n"), "mcp.json.agentplugins.lock": {}} + if err := c.acceptTreeError(root, files, false); err != nil || c.checkError() != nil { + t.Fatal("exact source-defined writer frame refused", err) + } + return c, root, files +} + +func TestCursorCycleWriterFrameMatchesDefaultKernel(t *testing.T) { + cursorCycleDefaultWriterFrame(t) +} + +func TestCursorCycleWriterFrameRetainsExactAuthority(t *testing.T) { + for _, fault := range []string{"unknown-file", "unknown-directory", "body", "mode", "replacement", "alias", "hardlink", "missing", "late-timestamp"} { + t.Run(fault, func(t *testing.T) { + c, root, files := cursorCycleDefaultWriterFrame(t) + lock := filepath.Join(root, "mcp.json.agentplugins.lock") + var err error + switch fault { + case "unknown-file": + err = os.WriteFile(filepath.Join(root, "unknown.lock"), nil, 0600) + case "unknown-directory": + err = os.Mkdir(filepath.Join(root, "unknown"), 0700) + case "body": + err = os.WriteFile(lock, []byte("foreign"), 0600) + case "mode": + err = os.Chmod(lock, 0400) + case "replacement", "alias": + other := filepath.Join(t.TempDir(), "replacement") + projectNativeRuntimeWrite(t, other, nil, 0600) + if fault == "replacement" { + err = os.Rename(other, lock) + } else if err = os.Remove(lock); err == nil { + err = os.Symlink(other, lock) + } + case "hardlink": + err = os.Link(lock, filepath.Join(t.TempDir(), "second-link")) + case "missing": + err = os.Remove(lock) + case "late-timestamp": + stamp := time.Unix(100, 0) + err = os.Chtimes(lock, stamp, stamp) + } + if err != nil { + t.Fatal(err) + } + if c.acceptTreeError(root, files, false) == nil || c.checkError() == nil { + t.Fatal("writer frame adopted a late membership or authority change") + } + }) + } +} diff --git a/repotests/agentplugins_cursor_project_native_approval_contract_test.go b/repotests/agentplugins_cursor_project_native_approval_contract_test.go new file mode 100644 index 000000000..728851ee5 --- /dev/null +++ b/repotests/agentplugins_cursor_project_native_approval_contract_test.go @@ -0,0 +1,160 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +const cursorApprovalConfig = `{"mcpServers":{"uapcursora":{"args":["--internal-stdio-v1","/owned/managed","/owned/data","/owned/managed","plugin","--","./bin/probe","cursor-component"],"command":"/owned/managed/agentplugins","env":{"PLUGIN_DATA":"/owned/data","PLUGIN_ROOT":"/owned/managed","UAP_TEST_EVENTS":"/owned/events.jsonl","UAP_TEST_NONCE":"0123456789abcdef0123456789abcdef","UAP_TEST_REVISION":"cursor-a-one","UAP_TEST_ROOT":"/owned"}}}}` + +func TestCursorApprovalUsesPinnedParserOrderAndExactProject(t *testing.T) { + path, body, err := cursorApprovalRecipe("/owned/home", "/owned/A", []byte(cursorApprovalConfig)) + if err != nil || path != "/owned/home/.cursor/projects/owned-A/mcp-approvals.json" || string(body) != "[\n \"uapcursora-78ea85b1ebb83201\"\n]" { + t.Fatal("source-derived approval fingerprint/path/format differs", err) + } + var object map[string]any + if json.Unmarshal([]byte(cursorApprovalConfig), &object) != nil { + t.Fatal("invalid literal contract") + } + server := object["mcpServers"].(map[string]any)["uapcursora"].(map[string]any) + ordered, _ := json.Marshal(struct { + Command any `json:"command"` + Env any `json:"env"` + Args any `json:"args"` + }{server["command"], server["env"], server["args"]}) + reordered := []byte(`{"mcpServers":{"uapcursora":` + string(ordered) + `}}`) + _, actual, err := cursorApprovalRecipe("/owned/home", "/owned/A", reordered) + if err != nil || !bytes.Equal(actual, body) { + t.Fatal("raw config property order changed parsed fingerprint", err) + } + _, foreign, err := cursorApprovalRecipe("/owned/home", "/owned/B", reordered) + if err != nil || bytes.Equal(foreign, body) { + t.Fatal("approval was not bound to the exact selected project") + } + for _, raw := range []string{ + strings.Replace(cursorApprovalConfig, `"mcpServers":`, `"unknown":true,"mcpServers":`, 1), + strings.Replace(cursorApprovalConfig, `"args":`, `"type":"stdio","args":`, 1), + strings.Replace(cursorApprovalConfig, `"command":"/owned/managed/agentplugins"`, `"command":false`, 1), + strings.ReplaceAll(cursorApprovalConfig, "/owned/data", "${PLUGIN_DATA}"), + strings.ReplaceAll(cursorApprovalConfig, "/owned/data", "/owned/é"), + strings.Replace(cursorApprovalConfig, `"env":`, `"cwd":"/owned","env":`, 1), + } { + if _, _, err := cursorApprovalRecipe("/owned/home", "/owned/A", []byte(raw)); err == nil { + t.Fatal("unreviewed approval schema/encoding accepted") + } + } +} + +func cursorApprovalFixture(t *testing.T) (*cursorProjectAttempt, string, []byte) { + t.Helper() + root := t.TempDir() + home := filepath.Join(root, "home") + if err := os.Mkdir(home, 0700); err != nil { + t.Fatal(err) + } + f := &cursorProjectAttempt{base: &nativeFixture{Root: root, Home: home, Project: filepath.Join(root, "A"), Events: filepath.Join(root, "events.jsonl")}, held: map[string]cursorProjectHeld{}, evidence: map[string]any{"installed_config": cursorApprovalConfig}} + path, body, err := cursorApprovalRecipe(home, f.base.Project, []byte(cursorApprovalConfig)) + if err != nil || os.MkdirAll(filepath.Dir(path), 0755) != nil { + t.Fatal("inert approval fixture", err) + } + f.absent = []string{path, filepath.Join(filepath.Dir(path), "mcp-disabled.json")} + return f, path, body +} + +func TestCursorApprovalAcceptsOnlyNativePhaseExactBytesAndAuthority(t *testing.T) { + for _, fault := range []string{"none", "wrong-fingerprint", "newline", "mode", "hardlink", "alias", "missing-phase", "unexpected-probe"} { + t.Run(fault, func(t *testing.T) { + f, path, body := cursorApprovalFixture(t) + if f.check() != nil { + t.Fatal("initial approval absence differs") + } + projectNativeRuntimeWrite(t, path, body, 0644) + if f.check() == nil { + t.Fatal("approval was accepted before the registered native phase") + } + switch fault { + case "wrong-fingerprint", "newline": + if fault == "wrong-fingerprint" { + body = []byte("[\"uapcursora-0000000000000000\"]") + } else { + body = append(body, '\n') + } + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatal(err) + } + case "mode": + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + case "hardlink": + if err := os.Link(path, path+"-alias"); err != nil { + t.Fatal(err) + } + case "alias": + if err := os.Rename(path, path+"-target"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(path+"-target", path); err != nil { + t.Fatal(err) + } + case "missing-phase": + f.absent = nil + case "unexpected-probe": + projectNativeRuntimeWrite(t, f.base.Events, []byte("unexpected"), 0600) + } + accepted := f.acceptApproval() + if (accepted == nil) != (fault == "none") { + t.Fatal("native approval authority differs", accepted) + } + if accepted == nil && f.check() != nil { + t.Fatal("accepted native approval failed its held check") + } + }) + } +} + +func TestCursorApprovalRemainsHeldAcrossLaterChildren(t *testing.T) { + for _, fault := range []string{"bytes", "inode", "parent-mode", "parent-inode", "disabled-store"} { + t.Run(fault, func(t *testing.T) { + f, path, body := cursorApprovalFixture(t) + projectNativeRuntimeWrite(t, path, body, 0644) + if err := f.acceptApproval(); err != nil { + t.Fatal(err) + } + switch fault { + case "bytes": + if err := os.WriteFile(path, []byte("[]"), 0644); err != nil { + t.Fatal(err) + } + case "inode": + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + projectNativeRuntimeWrite(t, path, body, 0644) + case "parent-mode": + if err := os.Chmod(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + case "parent-inode": + if err := os.Rename(filepath.Dir(path), filepath.Dir(path)+"-old"); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + projectNativeRuntimeWrite(t, path, body, 0644) + case "disabled-store": + projectNativeRuntimeWrite(t, f.absent[0], []byte("[]"), 0644) + } + if f.check() == nil { + t.Fatal("late native approval drift accepted") + } + }) + } +} diff --git a/repotests/agentplugins_cursor_project_native_approval_test.go b/repotests/agentplugins_cursor_project_native_approval_test.go new file mode 100644 index 000000000..17fe8963b --- /dev/null +++ b/repotests/agentplugins_cursor_project_native_approval_test.go @@ -0,0 +1,141 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "syscall" +) + +func cursorApprovalDirectory(home, project string) string { + key := regexp.MustCompile(`[^a-zA-Z0-9]+`).ReplaceAllString(project, "-") + key = regexp.MustCompile(`^-+|-+$`).ReplaceAllString(key, "") + return filepath.Join(home, ".cursor", "projects", key) +} + +// The pinned JSONC/Zod parser emits stdio fields in schema order. Its record +// parser retains the fixed native config's sorted environment keys. +func cursorApprovalRecipe(home, project string, config []byte) (string, []byte, error) { + for _, path := range []string{home, project} { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == string(filepath.Separator) { + return "", nil, fmt.Errorf("Cursor approval root differs") + } + } + value, err := projectNativeRuntimeJSON(config) + object, ok := value.(map[string]any) + if err != nil || !ok || projectNativeRuntimeKeys(object, "mcpServers", "") != nil { + return "", nil, fmt.Errorf("Cursor approval config differs") + } + servers, ok := object["mcpServers"].(map[string]any) + server, serverOK := servers["uapcursora"].(map[string]any) + if !ok || len(servers) != 1 || !serverOK || projectNativeRuntimeKeys(server, "command args env", "") != nil { + return "", nil, fmt.Errorf("Cursor approval selected server differs") + } + var parsed struct { + Command string `json:"command"` + Args []string `json:"args"` + Env map[string]string `json:"env"` + } + serverRaw, err := json.Marshal(server) + if err != nil || json.Unmarshal(serverRaw, &parsed) != nil || parsed.Command == "" || len(parsed.Args) != 8 || len(parsed.Env) != 6 { + return "", nil, fmt.Errorf("Cursor approval stdio shape differs") + } + for _, key := range []string{"PLUGIN_DATA", "PLUGIN_ROOT", "UAP_TEST_EVENTS", "UAP_TEST_NONCE", "UAP_TEST_REVISION", "UAP_TEST_ROOT"} { + if parsed.Env[key] == "" { + return "", nil, fmt.Errorf("Cursor approval environment differs") + } + } + projection := struct { + Path string `json:"path"` + Server any `json:"server"` + }{project, parsed} + raw, err := json.Marshal(projection) + if err != nil || bytes.Contains(raw, []byte("${")) || bytes.Contains(raw, []byte(`\u`)) { + return "", nil, fmt.Errorf("Cursor approval fixed encoding differs") + } + for _, b := range raw { + if b > 127 { + return "", nil, fmt.Errorf("Cursor approval non-ASCII recipe") + } + } + sum := sha256.Sum256(raw) + body, err := json.MarshalIndent([]string{fmt.Sprintf("uapcursora-%x", sum[:8])}, "", " ") + return filepath.Join(cursorApprovalDirectory(home, project), "mcp-approvals.json"), body, err +} + +func (f *cursorProjectAttempt) acceptApproval() error { + config, ok := f.evidence["installed_config"].(string) + if !ok { + return fmt.Errorf("Cursor approval needs held installed config") + } + path, expected, err := cursorApprovalRecipe(f.base.Home, f.base.Project, []byte(config)) + if err != nil { + return err + } + body, err := projectNativeRuntimeRead(path, 4096) + info, statErr := os.Lstat(path) + if err != nil || statErr != nil || !bytes.Equal(body, expected) || info.Mode() != 0644 { + return fmt.Errorf("Cursor native approval bytes/mode differ") + } + identity, ok := info.Sys().(*syscall.Stat_t) + if !ok || identity.Uid != uint32(os.Getuid()) || identity.Gid != uint32(os.Getgid()) || identity.Nlink != 1 { + return fmt.Errorf("Cursor native approval ownership differs") + } + if err := f.hold(path); err != nil { + return err + } + held := f.held[path] + if held.digest != fmt.Sprintf("%x", sha256.Sum256(expected)) || !os.SameFile(info, held.info) || !projectNativeRuntimeStableStat(info.Sys(), held.info.Sys()) { + return fmt.Errorf("Cursor approval changed before hold") + } + f.approvalParents = map[string]os.FileInfo{} + for parent := filepath.Dir(path); parent != f.base.Home; parent = filepath.Dir(parent) { + info, err := os.Lstat(parent) + if err != nil || !info.IsDir() || info.Mode() != os.ModeDir|0755 { + return fmt.Errorf("Cursor approval parent differs") + } + identity, ok := info.Sys().(*syscall.Stat_t) + if !ok || identity.Uid != uint32(os.Getuid()) || identity.Gid != uint32(os.Getgid()) { + return fmt.Errorf("Cursor approval parent ownership differs") + } + f.approvalParents[parent] = info + } + found := false + for i, absent := range f.absent { + if absent == path { + f.absent = append(f.absent[:i], f.absent[i+1:]...) + found = true + break + } + } + if !found { + return fmt.Errorf("Cursor approval phase was not absent") + } + events, err := projectNativeRuntimeRead(f.base.Events, 1<<20) + if (!os.IsNotExist(err) && err != nil) || len(events) != 0 { + return fmt.Errorf("Cursor approval unexpectedly launched the probe") + } + f.evidence["native_approval"] = map[string]any{"path": path, "body": string(body), "pre_tools_probe_bytes": 0, "pre_tools_probe_sha256": fmt.Sprintf("%x", sha256.Sum256(nil))} + return nil +} + +func (f *cursorProjectAttempt) checkApprovalParents() error { + for path, before := range f.approvalParents { + after, err := os.Lstat(path) + if err != nil || !os.SameFile(before, after) || before.Mode() != after.Mode() { + return fmt.Errorf("Cursor approval parent identity changed") + } + a, okA := before.Sys().(*syscall.Stat_t) + b, okB := after.Sys().(*syscall.Stat_t) + if !okA || !okB || a.Uid != b.Uid || a.Gid != b.Gid { + return fmt.Errorf("Cursor approval parent owner changed") + } + } + return nil +} diff --git a/repotests/agentplugins_cursor_project_native_attempt_test.go b/repotests/agentplugins_cursor_project_native_attempt_test.go new file mode 100644 index 000000000..28e94e8c1 --- /dev/null +++ b/repotests/agentplugins_cursor_project_native_attempt_test.go @@ -0,0 +1,288 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "testing" + "time" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/process" + "github.com/777genius/plugin-kit-ai/install/integrationctl/ports" +) + +const cursorProjectVersion = "2026.10.22-27dcf5f" +const cursorProjectArchive = "85296944e4600a83c4b764f578d1502bf9c416db200d33a593e8f3ecabfdc294" + +type cursorProjectCommand struct { + label, cwd string + argv []string +} +type cursorProjectHeld struct { + info os.FileInfo + digest string +} +type cursorProjectAttempt struct { + base *nativeFixture + client, installer, git, other, outside, output string + ctx context.Context + cancel context.CancelFunc + started time.Time + files map[string][]byte + held map[string]cursorProjectHeld + runtimeRoot string + runtimeMembers map[string]string + approvalParents map[string]os.FileInfo + absent []string + evidence map[string]any + cycle *cursorMCPProjectCycle +} + +func (f *cursorProjectAttempt) commands() []cursorProjectCommand { + a := f.base.Project + return []cursorProjectCommand{ + {"git-a", a, []string{f.git, "init", "--quiet", "--initial-branch=main"}}, + {"git-b", f.other, []string{f.git, "init", "--quiet", "--initial-branch=main"}}, + {"version", f.outside, []string{f.client, "--version"}}, + {"empty-a-mcp", a, []string{f.client, "mcp", "list"}}, + {"empty-b-mcp", f.other, []string{f.client, "mcp", "list"}}, + {"empty-outside-mcp", f.outside, []string{f.client, "mcp", "list"}}, + {"add-a", a, []string{f.installer, "add", f.base.Package, "--no-save", "--scope", "project", "--project-root", a, "--target", "cursor", "--format", "json"}}, + {"native-attempt", a, []string{f.client, "mcp", "list"}}, + {"approve-a-mcp", a, []string{f.client, "mcp", "enable", "uapcursora"}}, + {"native-tools-a", a, []string{f.client, "mcp", "list-tools", "uapcursora"}}, + {"control-b-mcp", f.other, []string{f.client, "mcp", "list"}}, + {"control-outside-mcp", f.outside, []string{f.client, "mcp", "list"}}, + } +} +func (f *cursorProjectAttempt) hold(path string) error { + digest, err := projectNativeRuntimeDigest(path) + if err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + f.held[path] = cursorProjectHeld{info, digest} + return nil +} +func (f *cursorProjectAttempt) check() error { + if err := f.checkApprovalParents(); err != nil { + return err + } + if f.runtimeRoot != "" { + if err := f.runtimeClosure(); err != nil { + return err + } + } + for path, want := range f.held { + digest, err := projectNativeRuntimeDigest(path) + info, statErr := os.Lstat(path) + if err != nil || statErr != nil || digest != want.digest || !os.SameFile(info, want.info) || !projectNativeRuntimeStableStat(info.Sys(), want.info.Sys()) { + return fmt.Errorf("Cursor held file identity changed: %s", path) + } + } + for _, path := range f.absent { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + return fmt.Errorf("Cursor other-source absence changed: %s", path) + } + } + return nil +} +func (f *cursorProjectAttempt) keep(t *testing.T, name string, body []byte) { + t.Helper() + total := len(body) + for _, previous := range f.files { + total += len(previous) + } + _, exists := f.files[name] + if filepath.Base(name) != name || exists || len(f.files) >= 38 || len(body) > 1<<20 || total > 16<<20 { + t.Fatal("Cursor closed capture bound") + } + f.files[name] = body +} +func (f *cursorProjectAttempt) command(t *testing.T, command cursorProjectCommand) ports.CommandResult { + t.Helper() + if f.cycle != nil { + if err := f.cycle.admit(command); err != nil { + t.Fatal(err) + } + f.cycle.beforeProbe(t) + } + if err := f.check(); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(f.ctx, 30*time.Second) + defer cancel() + began := time.Now() + env := append(nativePlatformEnvironment(f.base.Root, f.base.Home, filepath.Dir(f.client), filepath.Dir(f.git)), "AGENTPLUGINS_HOME="+filepath.Join(f.base.Root, "installer-state"), "NO_COLOR=1", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_TERMINAL_PROMPT=0") + result, err := (process.OS{}).RunWithDescendantExitGrace(ctx, ports.Command{Argv: command.argv, Env: env, Dir: command.cwd, StdoutLimitBytes: 1 << 20}, 5*time.Second) + ended := time.Now() + meta := map[string]any{"argv": command.argv, "cwd": command.cwd, "exit": result.ExitCode, "error": err != nil, "deadline": ctx.Err() != nil, "elapsed_ms": ended.Sub(began).Milliseconds(), "cleanup": "full descendant supervision; forced cleanup fails"} + if f.cycle != nil { + f.cycle.capture(t, command, result, meta, began, ended) + } else { + receipt, _ := json.Marshal(meta) + f.keep(t, command.label+".command.json", receipt) + f.keep(t, command.label+".stdout.log", result.Stdout) + f.keep(t, command.label+".stderr.log", result.Stderr) + } + if f.cycle == nil && command.label == "approve-a-mcp" { + if err != nil || ctx.Err() != nil || result.ExitCode != 0 { + t.Fatal("Cursor exact owned MCP approval command failed") + } + if err := f.acceptApproval(); err != nil { + t.Fatal(err) + } + } + if err := f.check(); err != nil { + t.Fatal(err) + } + allowedExit := f.cycle != nil && f.cycle.allowedExit(command, result) + if err != nil || ctx.Err() != nil || (result.ExitCode != 0 && !allowedExit) || bytes.Contains(result.Stderr, []byte("stderr bytes omitted")) { + t.Fatalf("Cursor %s process/capture/cleanup failed; see bounded capture", command.label) + } + return result +} +func newCursorProjectAttempt(t *testing.T) *cursorProjectAttempt { + t.Helper() + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" || os.Getenv("GITHUB_ACTIONS") != "true" || os.Getenv("RUNNER_ENVIRONMENT") != "github-hosted" || os.Getenv("AGENTPLUGINS_NATIVE_DISPOSABLE_HOSTED") != "1" { + t.Fatal("Cursor attempt requires actual opted-in disposable GitHub-hosted Linux amd64") + } + f := &cursorProjectAttempt{started: time.Now(), files: map[string][]byte{}, held: map[string]cursorProjectHeld{}, evidence: map[string]any{"client": "cursor", "version": cursorProjectVersion, "activation": "prepared", "runtime_attempt_complete": false, "vendor_authentication_qualified": false, "real_model_qualified": false, "qualification": map[string]bool{"project-lifecycle": false, "native-restart-removal": false, "mcp-initialize": false, "mcp-tools-list": false, "mcp-tools-call": false}}} + f.ctx, f.cancel = context.WithDeadline(context.Background(), f.started.Add(150*time.Second)) + f.output = os.Getenv("AGENTPLUGINS_PROJECT_NATIVE_OUTPUT") + if err := projectNativeRuntimeParents(filepath.Join(f.output, "leaf")); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(f.output) + if err != nil || len(entries) != 0 { + t.Fatal("Cursor output must be existing empty ordinary directory") + } + f.client = nativeBinary(t, "AGENTPLUGINS_CURSOR_BIN") + f.installer = nativeBinary(t, "AGENTPLUGINS_INSTALLER_BIN") + f.git, err = exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + f.freezeAcquisition(t) + for _, path := range []string{f.installer, f.git, nativeBinary(t, "AGENTPLUGINS_NATIVE_PROBE_BIN"), nativeBinary(t, "AGENTPLUGINS_LINTAI_BIN")} { + if err := f.hold(path); err != nil { + t.Fatal(err) + } + } + source, err := nativeSourceIdentity(os.Getenv("AGENTPLUGINS_INSTALLER_COMMIT"), os.Getenv("AGENTPLUGINS_INSTALLER_TREE"), os.Getenv("AGENTPLUGINS_INSTALLER_PATCH_SHA256")) + if err != nil { + t.Fatal(err) + } + f.evidence["source_identity"] = source + f.base = newNativeFixture(t, false) + f.other = filepath.Join(f.base.Root, "project-B") + f.outside = filepath.Join(f.base.Root, "outside") + for _, path := range []string{f.other, f.outside} { + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } + } + f.absent = []string{filepath.Join(f.base.Home, ".cursor", "mcp.json"), filepath.Join(f.other, ".cursor", "mcp.json"), filepath.Join(f.outside, ".cursor", "mcp.json"), filepath.Join(f.base.Root, ".cursor", "mcp.json")} + f.evidence["other_source_absent"] = append([]string(nil), f.absent...) + for _, project := range []string{f.base.Project, f.other, f.outside} { + directory := cursorApprovalDirectory(f.base.Home, project) + f.absent = append(f.absent, filepath.Join(directory, "mcp-approvals.json"), filepath.Join(directory, "mcp-disabled.json")) + } + f.evidence["approval_controls_initially_absent"] = append([]string(nil), f.absent[4:]...) + scanner := nativeProvisionScanner(t, f.base) + f.evidence["scanner"] = scanner + if err := f.hold(filepath.Join(f.base.Root, "installer-state", "security", "lintai", "0.1.3", scanner["platform"], "lintai")); err != nil { + t.Fatal(err) + } + if err := f.hold(f.base.Probe); err != nil { + t.Fatal(err) + } + return f +} +func (f *cursorProjectAttempt) finish(t *testing.T) { + t.Helper() + defer f.cancel() + if err := f.check(); err != nil { + t.Error(err) + } + if time.Since(f.started) > 180*time.Second { + t.Error("Cursor total attempt budget exceeded") + } + events, err := projectNativeRuntimeRead(f.base.Events, 1<<20) + if os.IsNotExist(err) { + events = []byte{} + } else if err != nil { + t.Error(err) + } + if f.cycle == nil { + f.keep(t, "probe-events.jsonl", events) + } else { + f.cycle.finish(t, events) + } + f.evidence["test_failed"] = t.Failed() + f.evidence["elapsed_ms"] = time.Since(f.started).Milliseconds() + body, err := json.MarshalIndent(f.evidence, "", " ") + if err != nil { + t.Error(err) + return + } + f.keep(t, "runtime-attempt.json", append(body, '\n')) + file, err := os.OpenFile(filepath.Join(f.output, "research.zip"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + t.Error(err) + return + } + archive := zip.NewWriter(file) + names := make([]string, 0, len(f.files)) + for name := range f.files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + writer, err := archive.Create(name) + if err != nil { + t.Error(err) + break + } + if _, err := writer.Write(f.files[name]); err != nil { + t.Error(err) + break + } + } + if err := archive.Close(); err != nil { + t.Error(err) + } + if err := file.Close(); err != nil { + t.Error(err) + } +} +func TestAgentpluginsCursorNativeProjectRuntimeAttempt(t *testing.T) { + if os.Getenv("AGENTPLUGINS_CURSOR_PROJECT_RUNTIME_ATTEMPT") != "1" { + t.Skip("opt-in current-source Cursor component capture") + } + f := newCursorProjectAttempt(t) + defer f.finish(t) + f.packageSource(t) + for _, command := range f.commands() { + result := f.command(t, command) + if command.label == "version" && (string(result.Stdout) != cursorProjectVersion+"\n" || len(result.Stderr) != 0) { + t.Fatal("exact Cursor pinned version differs") + } + if command.label == "add-a" { + f.installed(t, result.Stdout) + } + } + t.Fatal("Cursor approved MCP tools capture requires independent raw review; all runtime qualifications remain false") +} diff --git a/repotests/agentplugins_cursor_project_native_contract_test.go b/repotests/agentplugins_cursor_project_native_contract_test.go new file mode 100644 index 000000000..55f9e5d93 --- /dev/null +++ b/repotests/agentplugins_cursor_project_native_contract_test.go @@ -0,0 +1,140 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestCursorProjectCaptureCommandAuthority(t *testing.T) { + f := &cursorProjectAttempt{base: &nativeFixture{Project: "/owned/A", Package: "/owned/source"}, client: "/pin/dist-package/cursor-agent", installer: "/owned/installer", git: "/usr/bin/git", other: "/owned/B", outside: "/owned/outside"} + want := []cursorProjectCommand{ + {"git-a", "/owned/A", []string{"/usr/bin/git", "init", "--quiet", "--initial-branch=main"}}, + {"git-b", "/owned/B", []string{"/usr/bin/git", "init", "--quiet", "--initial-branch=main"}}, + {"version", "/owned/outside", []string{"/pin/dist-package/cursor-agent", "--version"}}, + {"empty-a-mcp", "/owned/A", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + {"empty-b-mcp", "/owned/B", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + {"empty-outside-mcp", "/owned/outside", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + {"add-a", "/owned/A", []string{"/owned/installer", "add", "/owned/source", "--no-save", "--scope", "project", "--project-root", "/owned/A", "--target", "cursor", "--format", "json"}}, + {"native-attempt", "/owned/A", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + {"approve-a-mcp", "/owned/A", []string{"/pin/dist-package/cursor-agent", "mcp", "enable", "uapcursora"}}, + {"native-tools-a", "/owned/A", []string{"/pin/dist-package/cursor-agent", "mcp", "list-tools", "uapcursora"}}, + {"control-b-mcp", "/owned/B", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + {"control-outside-mcp", "/owned/outside", []string{"/pin/dist-package/cursor-agent", "mcp", "list"}}, + } + if !reflect.DeepEqual(f.commands(), want) { + t.Fatal("Cursor first component call authority changed") + } +} +func TestCursorProjectCaptureHeldAndAbsentAuthority(t *testing.T) { + for _, change := range []string{"bytes", "replacement", "alias", "hardlink", "other-source"} { + t.Run(change, func(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "held") + projectNativeRuntimeWrite(t, path, []byte("held bytes"), 0600) + f := &cursorProjectAttempt{held: map[string]cursorProjectHeld{}, absent: []string{filepath.Join(root, "absent")}} + if err := f.hold(path); err != nil { + t.Fatal(err) + } + if err := f.check(); err != nil { + t.Fatal(err) + } + switch change { + case "bytes": + if err := os.WriteFile(path, []byte("changed"), 0600); err != nil { + t.Fatal(err) + } + case "replacement", "alias": + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if change == "replacement" { + projectNativeRuntimeWrite(t, path, []byte("held bytes"), 0600) + } else if err := os.Symlink(f.absent[0], path); err != nil { + t.Fatal(err) + } + case "hardlink": + if err := os.Link(path, filepath.Join(root, "alias")); err != nil { + t.Fatal(err) + } + case "other-source": + projectNativeRuntimeWrite(t, f.absent[0], []byte("{}"), 0600) + } + if f.check() == nil { + t.Fatal("changed Cursor held/other-source authority accepted") + } + }) + } +} +func TestCursorProjectCaptureRuntimeClosure(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "member") + projectNativeRuntimeWrite(t, path, []byte("runtime"), 0600) + f := &cursorProjectAttempt{runtimeRoot: root, runtimeMembers: map[string]string{path: "file"}} + if err := f.runtimeClosure(); err != nil { + t.Fatal(err) + } + unknown := filepath.Join(root, "unknown") + projectNativeRuntimeWrite(t, unknown, []byte("extra"), 0600) + if f.runtimeClosure() == nil { + t.Fatal("unknown runtime file accepted") + } + if err := os.Remove(unknown); err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(unknown, path); err != nil { + t.Fatal(err) + } + if f.runtimeClosure() == nil { + t.Fatal("aliased runtime member accepted") + } +} + +func TestCursorProjectCaptureMaterializedDirectoryAuthority(t *testing.T) { + root := t.TempDir() + bundle, leaf := filepath.Join(root, "bundle"), filepath.Join(root, "bundle", "client") + empty := filepath.Join(root, "bundle", "empty") + archive := map[string]string{bundle: "directory", leaf: "file", empty: "directory", filepath.Join(empty, "nested"): "directory"} + expected := map[string]string{bundle: "directory", leaf: "file"} + members, err := cursorProjectMaterializedMembers(root, archive) + if err != nil || !reflect.DeepEqual(members, expected) { + t.Fatal("materialized authority differs from literal file/parent set", err) + } + projectNativeRuntimeWrite(t, leaf, []byte("inert runtime"), 0600) + f := &cursorProjectAttempt{runtimeRoot: root, runtimeMembers: members} + if err := f.runtimeClosure(); err != nil { + t.Fatal("unmaterialized empty archive directories were required", err) + } + if err := os.Mkdir(empty, 0700); err != nil { + t.Fatal(err) + } + if f.runtimeClosure() == nil { + t.Fatal("observed empty archive directory was adopted into authority") + } + if err := os.Remove(empty); err != nil { + t.Fatal(err) + } + if err := os.Remove(leaf); err != nil { + t.Fatal(err) + } + if f.runtimeClosure() == nil { + t.Fatal("removed required runtime file accepted") + } + for _, unsafe := range []string{root, filepath.Dir(root)} { + if _, err := cursorProjectMaterializedMembers(root, map[string]string{unsafe: "directory"}); err == nil { + t.Fatal("root or parent archive directory was silently ignored") + } + } + for _, kind := range []string{"", "file"} { + archive[bundle] = kind + if _, err := cursorProjectMaterializedMembers(root, archive); err == nil { + t.Fatal("missing/non-directory trusted parent accepted") + } + } +} diff --git a/repotests/agentplugins_cursor_project_native_proof_test.go b/repotests/agentplugins_cursor_project_native_proof_test.go new file mode 100644 index 000000000..7a527b8f1 --- /dev/null +++ b/repotests/agentplugins_cursor_project_native_proof_test.go @@ -0,0 +1,266 @@ +//go:build linux + +package pluginkitairepo_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/managedstdio" +) + +type cursorProjectIdentity struct { + Client string `json:"client"` + Acquisition struct { + SHA string `json:"sha256"` + Pin bool `json:"pin_verified"` + Members []struct { + Name string `json:"name"` + Kind string `json:"kind"` + SHA string `json:"sha256"` + } `json:"members"` + } `json:"acquisition"` + RuntimeFiles []struct { + Path string `json:"path"` + SHA string `json:"sha256"` + } `json:"runtime_files"` +} + +func (f *cursorProjectAttempt) freezeAcquisition(t *testing.T) { + t.Helper() + body, err := projectNativeRuntimeRead(os.Getenv("AGENTPLUGINS_PROJECT_NATIVE_IDENTITY"), 1<<20) + if err != nil { + t.Fatal(err) + } + if _, err := projectNativeRuntimeJSON(body); err != nil { + t.Fatal(err) + } + var identity cursorProjectIdentity + if json.Unmarshal(body, &identity) != nil || identity.Client != "cursor" || !identity.Acquisition.Pin || identity.Acquisition.SHA != cursorProjectArchive || len(identity.Acquisition.Members) != 565 || len(identity.RuntimeFiles) != 445 { + t.Fatal("Cursor exact acquisition identity differs") + } + f.runtimeRoot = filepath.Dir(filepath.Dir(f.client)) + archiveMembers := map[string]string{} + expected := map[string]string{} + for _, member := range identity.Acquisition.Members { + name := member.Name + if name == "" || name == "." || name == ".." || filepath.IsAbs(name) || filepath.Clean(name) != name || strings.Contains(name, "\\") || strings.HasPrefix(name, "../") || (member.Kind != "file" && member.Kind != "directory") { + t.Fatal("Cursor acquisition member differs") + } + path := filepath.Join(f.runtimeRoot, name) + if archiveMembers[path] != "" { + t.Fatal("Cursor duplicate acquisition member") + } + archiveMembers[path] = member.Kind + if member.Kind == "file" { + expected[path] = member.SHA + } + } + f.runtimeMembers, err = cursorProjectMaterializedMembers(f.runtimeRoot, archiveMembers) + if err != nil { + t.Fatal(err) + } + if len(expected) != 445 || expected[f.client] != "2ccc9a8e167797641448b5e5c936f006ba137a2555f117f38c5eb76a5238a233" || expected[filepath.Join(filepath.Dir(f.client), "node")] != "e32e2d16081cf644f88392934288086e7662529d0d43277c34f7aad2207cfafb" || expected[filepath.Join(filepath.Dir(f.client), "index.js")] != "7b4bb3badb36cff7b6cee7996d61bd3c32866cf354214374b77f5a5d33a0b3f2" { + t.Fatal("Cursor launcher/bundled Node/index closure differs") + } + for _, file := range identity.RuntimeFiles { + if expected[file.Path] == "" || expected[file.Path] != file.SHA || f.held[file.Path].info != nil { + t.Fatal("Cursor runtime closure differs") + } + if err := f.hold(file.Path); err != nil || f.held[file.Path].digest != file.SHA { + t.Fatal("Cursor runtime member digest differs") + } + } + if err := f.runtimeClosure(); err != nil { + t.Fatal(err) + } + f.evidence["acquisition"] = json.RawMessage(body) +} + +// The existing materializer writes regular files and creates only their parent +// chains. Empty archive directories are inventoried, but are not materialized. +func cursorProjectMaterializedMembers(root string, archive map[string]string) (map[string]string, error) { + result := map[string]string{} + for path, kind := range archive { + if !strings.HasPrefix(path, root+string(filepath.Separator)) { + return nil, fmt.Errorf("Cursor archive member is outside materialized root") + } + if kind != "file" { + continue + } + result[path] = "file" + for parent := filepath.Dir(path); parent != root; parent = filepath.Dir(parent) { + if archive[parent] != "directory" || parent == filepath.Dir(parent) { + return nil, fmt.Errorf("Cursor materialized parent is absent from trusted archive directories") + } + result[parent] = "directory" + } + } + return result, nil +} +func (f *cursorProjectAttempt) runtimeClosure() error { + seen := 0 + err := filepath.WalkDir(f.runtimeRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == f.runtimeRoot { + return nil + } + kind := f.runtimeMembers[path] + if (kind == "directory" && !entry.IsDir()) || (kind == "file" && !entry.Type().IsRegular()) || kind == "" { + return fmt.Errorf("Cursor unknown/aliased runtime member") + } + seen++ + return nil + }) + if err != nil || seen != len(f.runtimeMembers) { + return fmt.Errorf("Cursor complete runtime member set changed") + } + return nil +} +func (f *cursorProjectAttempt) packageSource(t *testing.T) { + t.Helper() + manifest := []byte("{\"$schema\":\"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json\",\"name\":\"native-proof\",\"version\":\"1.0.0\"}\n") + server := map[string]any{"type": "stdio", "command": "./bin/probe", "args": []string{"cursor-component"}, "cwd": "${PLUGIN_ROOT}", "env": map[string]string{"UAP_TEST_ROOT": f.base.Root, "UAP_TEST_EVENTS": f.base.Events, "UAP_TEST_NONCE": f.base.Nonce, "UAP_TEST_REVISION": "cursor-a-one"}} + config, err := json.Marshal(map[string]any{"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", "mcpServers": map[string]any{"uapcursora": server}}) + if err != nil { + t.Fatal(err) + } + probe, err := projectNativeRuntimeRead(f.base.Probe, 32<<20) + if err != nil { + t.Fatal(err) + } + authored := map[string][]byte{"plugin.json": manifest, "mcp.json": append(config, '\n'), "bin/probe": probe} + for relative, body := range authored { + path := filepath.Join(f.base.Package, relative) + mode := os.FileMode(0600) + if relative == "bin/probe" { + mode = 0700 + } + projectNativeRuntimeWrite(t, path, body, mode) + if err := f.hold(path); err != nil { + t.Fatal(err) + } + } + f.evidence["fixture"] = map[string]any{"root": f.base.Root, "home": f.base.Home, "a": f.base.Project, "b": f.other, "outside": f.outside, "source": f.base.Package, "nonce": f.base.Nonce, "mcp_name": "uapcursora", "manifest": string(manifest), "mcp": string(config), "probe_sha256": f.held[f.base.Probe].digest} +} +func (f *cursorProjectAttempt) installed(t *testing.T, stdout []byte) { + t.Helper() + if _, err := projectNativeRuntimeJSON(stdout); err != nil { + t.Fatal(err) + } + if err := nativeValidateInstallerJSON(stdout); err != nil { + t.Fatal(err) + } + path := filepath.Join(f.base.Project, ".uap", "state-v2.json") + body, err := projectNativeRuntimeRead(path, 1<<20) + if err != nil { + t.Fatal(err) + } + var state domain.StateFileV2 + if json.Unmarshal(body, &state) != nil || len(state.Installations) != 1 { + t.Fatal("Cursor exact project installation missing") + } + installation := state.Installations[0] + if installation.DeclaredName != "native-proof" || installation.Source.CanonicalSource != f.base.Package || installation.Source.SourceBindingID == "" || installation.Source.TreeDigest == "" || installation.Package.DeclaredName != "native-proof" || installation.Package.Version != "1.0.0" || installation.Package.ManifestDigest == "" || len(installation.Clients) != 1 { + t.Fatal("Cursor selected source/package authority differs") + } + for _, binding := range installation.Clients { + f.installedBinding(t, binding, installation.DataReceipts[binding.DataReceiptID]) + } + if err := f.hold(path); err != nil { + t.Fatal(err) + } + f.evidence["installed_state"] = json.RawMessage(body) +} +func (f *cursorProjectAttempt) installedBinding(t *testing.T, binding domain.ClientBinding, data domain.DataReceipt) { + t.Helper() + if binding.ClientID != "cursor" || binding.Scope != "project" || binding.NativeProfileRoot != filepath.Join(f.base.Project, ".cursor") || binding.Activation != domain.ActivationPrepared || binding.Verification != domain.VerificationInstalled || data.State != domain.DataReceiptOwned || data.Scope != "project" || data.DataReceiptID != binding.DataReceiptID || data.PhysicalBackend != binding.PhysicalArtifact || data.OwnershipDigest == "" { + t.Fatal("Cursor Prepared binding/data receipt differs") + } + nativeRequireContained(t, filepath.Join(f.base.Project, ".uap", "managed"), binding.TargetLocator) + nativeRequireContained(t, filepath.Join(f.base.Project, ".uap", "plugin-data"), data.Locator) + configPath := filepath.Join(f.base.Project, ".cursor", "mcp.json") + owned := 0 + for _, object := range binding.NativeObjects { + if object.Kind == "cursor_project_mcp_server" { + if object.Path != configPath || object.LogicalName != "uapcursora" || object.ManagedDigest == "" { + t.Fatal("Cursor MCP ownership differs") + } + owned++ + } + } + if owned != 1 { + t.Fatal("Cursor single owned MCP receipt missing") + } + config, err := projectNativeRuntimeRead(configPath, 1<<20) + if err != nil { + t.Fatal(err) + } + f.configBinding(t, config, binding.TargetLocator, data.Locator) + for _, relative := range []string{"plugin.json", "bin/probe"} { + target := filepath.Join(binding.TargetLocator, relative) + body, err := projectNativeRuntimeRead(target, 32<<20) + source, sourceErr := projectNativeRuntimeRead(filepath.Join(f.base.Package, relative), 32<<20) + if err != nil || sourceErr != nil || !bytes.Equal(body, source) { + t.Fatal("Cursor installed source bytes differ") + } + if err := f.hold(target); err != nil { + t.Fatal(err) + } + } + projected := filepath.Join(binding.TargetLocator, "mcp.json") + projectedBody, err := projectNativeRuntimeRead(projected, 1<<20) + if err != nil { + t.Fatal(err) + } + f.configBinding(t, projectedBody, binding.TargetLocator, data.Locator) + if err := f.hold(projected); err != nil { + t.Fatal(err) + } + if err := f.hold(filepath.Join(data.Locator, ".agentplugins-data-owner.json")); err != nil { + t.Fatal(err) + } + launcher := filepath.Join(binding.TargetLocator, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) + if err := f.hold(launcher); err != nil { + t.Fatal(err) + } + if f.held[launcher].digest != f.held[f.installer].digest { + t.Fatal("Cursor managed launcher differs from held current installer") + } + if err := f.hold(configPath); err != nil { + t.Fatal(err) + } + f.evidence["installed_config"] = string(config) + f.evidence["binding"] = binding + f.evidence["data_receipt"] = data +} +func (f *cursorProjectAttempt) configBinding(t *testing.T, body []byte, active, data string) { + t.Helper() + value, err := projectNativeRuntimeJSON(body) + object, ok := value.(map[string]any) + if err != nil || !ok || projectNativeRuntimeKeys(object, "mcpServers", "") != nil { + t.Fatal("Cursor native config shape differs") + } + servers, ok := object["mcpServers"].(map[string]any) + if !ok || len(servers) != 1 { + t.Fatal("Cursor exact native MCP inventory differs") + } + server, ok := servers["uapcursora"].(map[string]any) + if !ok || projectNativeRuntimeKeys(server, "command args env", "") != nil { + t.Fatal("Cursor native MCP fields differ") + } + env := map[string]any{"UAP_TEST_ROOT": f.base.Root, "UAP_TEST_EVENTS": f.base.Events, "UAP_TEST_NONCE": f.base.Nonce, "UAP_TEST_REVISION": "cursor-a-one", "PLUGIN_ROOT": active, "PLUGIN_DATA": data} + args := []any{managedstdio.Mode, active, data, active, "plugin", "--argv-base64-v1", "Li9iaW4vcHJvYmU=", "Y3Vyc29yLWNvbXBvbmVudA=="} + if server["command"] != filepath.Join(active, filepath.FromSlash(managedstdio.RelativeDirectory), managedstdio.ExecutableName) || !reflect.DeepEqual(server["args"], args) || !reflect.DeepEqual(server["env"], env) { + t.Fatal("Cursor installed managed stdio recipe differs") + } +} diff --git a/scripts/project_native_collectors/cursor.py b/scripts/project_native_collectors/cursor.py new file mode 100644 index 000000000..586e3b0d3 --- /dev/null +++ b/scripts/project_native_collectors/cursor.py @@ -0,0 +1,59 @@ +"""Pinned read-only Cursor help and opaque empty MCP captures; no runtime proof.""" + +CLIENT = "cursor" +VERSION = "2026.10.22-27dcf5f" +AVAILABLE_PHASES = ("acquire", "help") +ACQUISITION = { + "url": "https://downloads.cursor.com/lab/2026.10.22-27dcf5f/linux/x64/agent-cli-package.tar.gz", + "integrity": "sha256:85296944e4600a83c4b764f578d1502bf9c416db200d33a593e8f3ecabfdc294", + "expected_bytes": 184859176, + "max_bytes": 256 * 1024 * 1024, + "max_expanded_bytes": 640 * 1024 * 1024, + "format": "tar.gz", + "entrypoint": "dist-package/cursor-agent", + "binary_sha256": "2ccc9a8e167797641448b5e5c936f006ba137a2555f117f38c5eb76a5238a233", + # This exact shell launcher selects its sibling bundled Node and index.js. + "runtime": "native", + "transport": "cursor-publisher-curl", +} + +PROBES = ( + ("cursor-version", ("--version",), "a", "version"), + ("cursor-help", ("--help",), "a", "help"), + ("cursor-mcp-help", ("mcp", "--help"), "a", "help"), + ("cursor-plugin-help", ("plugin", "--help"), "a", "help"), + ("cursor-empty-a-mcp", ("mcp", "list"), "a", "opaque-component-capture"), + ("cursor-empty-b-mcp", ("mcp", "list"), "b", "opaque-component-capture"), + ("cursor-empty-outside-mcp", ("mcp", "list"), "user", "opaque-component-capture"), +) + + +def collect(ctx): + """Use the frozen bundled launcher without seeding settings or approving MCP.""" + if getattr(ctx, "phase", None) != "help": + raise ValueError("Cursor collector accepts only its reviewed help phase") + observations = {} + for label, arguments, project, surface in PROBES: + record, stdout, stderr = ctx.probe(label, list(arguments), project=project, timeout=30) + if (not isinstance(record, dict) or type(record.get("exit")) is not int + or record["exit"] != 0 or "error" not in record or record["error"] is not None + or not isinstance(stdout, bytes) or not isinstance(stderr, bytes)): + raise ValueError("Cursor read-only probe failed; no supported boundary is inferred") + try: + text = stdout.decode("utf-8", errors="strict") + stderr.decode("utf-8", errors="strict") + except UnicodeError as error: + raise ValueError("Cursor read-only capture is not UTF-8") from error + if surface == "version" and (stdout != (VERSION + "\n").encode() or stderr): + raise ValueError("Cursor executable version differs from frozen publisher version") + if surface in ("version", "help") and not text.strip(): + raise ValueError("Cursor version/help capture is empty") + observations[label] = {"arguments": list(arguments), "project": project, "surface": surface} + return { + "observations": observations, + "cases": [{"id": "cursor.help", "status": "passed", "reason": + "Exact pinned version and read-only help observed; three empty-context MCP outputs remain opaque, without scope or runtime qualification"}], + "unobserved": ["cursor.scope-observation", "cursor.project-lifecycle", "cursor.native-restart-removal", + "cursor.mcp-initialize", "cursor.mcp-tools-list", "cursor.mcp-tools-call", + "cursor.vendor-authentication", "cursor.real-model"], + } diff --git a/scripts/project_native_cursor_acquisition.py b/scripts/project_native_cursor_acquisition.py new file mode 100644 index 000000000..e1d61d57d --- /dev/null +++ b/scripts/project_native_cursor_acquisition.py @@ -0,0 +1,65 @@ +"""Exact Cursor publisher transport; acquired payloads are never executed here.""" +import os +from pathlib import Path +import re +import stat +import tempfile + +import native_client_project_artifacts as artifacts +from native_client_research import bounded_read, run_probe + +URL = "https://downloads.cursor.com/lab/2026.10.22-27dcf5f/linux/x64/agent-cli-package.tar.gz" +TRANSPORT = "cursor-publisher-curl" + + +def download(spec, budget): + maximum = spec["max_bytes"] + if (spec.get("transport") != TRANSPORT or spec["url"] != URL + or spec["format"] != "tar.gz" or type(maximum) is not int + or not 1 <= maximum <= 256 << 20): + raise ValueError("unregistered Cursor publisher transport") + with tempfile.TemporaryDirectory(prefix="uap-cursor-acquire-") as temporary: + root = Path(temporary).resolve() + root.chmod(0o700) + env = {"PATH": "/usr/bin:/bin", "HOME": str(root), "LANG": "C", "LC_ALL": "C"} + version, stdout, _ = run_probe(["/usr/bin/curl", "-q", "--version"], root, env, root, + "cursor-curl-version", timeout=min(5, budget.check()), + limit=16384, memory_only=True) + match = re.match(rb"curl (\d+)\.(\d+)\.(\d+) ", stdout) + if (version["error"] is not None or version["exit"] != 0 or match is None + or tuple(int(value) for value in match.groups()) < (8, 4, 0)): + raise ValueError("Cursor publisher transport requires curl 8.4 or newer") + target = root / "archive.tar.gz" + with target.open("xb"): + target.chmod(0o600) + before = target.lstat() + timeout = min(90, budget.check()) + argv = ["/usr/bin/curl", "-q", "-fSL", "--silent", "--proto", "=https", + "--proto-redir", "=https", "--max-redirs", "0", "--connect-timeout", + str(min(10, timeout)), "--max-time", str(timeout), "--max-filesize", str(maximum), + "--output", str(target), "--write-out", "%{http_code}\n%{url_effective}\n", URL] + record, stdout, _ = run_probe(argv, root, env, root, "cursor-curl-download", + timeout=timeout, limit=16384, memory_only=True) + if record["error"] is not None or record["exit"] != 0 or stdout != b"200\n" + URL.encode() + b"\n": + code = stdout.split(b"\n", 1)[0] + status = code.decode("ascii") if re.fullmatch(rb"[0-9]{3}", code) else "unavailable" + raise ValueError(f"Cursor publisher acquisition failed: curl_exit={record['exit']}; " + f"http_status={status}; process_error={record['error'] is not None}") + after = target.lstat() + stable = ("st_dev", "st_ino", "st_mode", "st_nlink", "st_uid", "st_gid") + if (not stat.S_ISREG(after.st_mode) or after.st_nlink != 1 or after.st_uid != os.getuid() + or not 0 < after.st_size <= maximum or target.resolve() != target + or any(getattr(before, field) != getattr(after, field) for field in stable)): + raise ValueError("Cursor publisher archive identity refused") + descriptor = artifacts.open_member(target) + with os.fdopen(descriptor, "rb") as source: + held = os.fstat(source.fileno()) + if not artifacts.same_opened(after, held): + raise ValueError("Cursor publisher archive changed while opening") + budget.check() + body = bounded_read(source, maximum) + budget.check() + if (len(body) != after.st_size or not artifacts.same_opened(held, os.fstat(source.fileno())) + or not artifacts.same_opened(after, target.lstat())): + raise ValueError("Cursor publisher archive changed while reading") + return body, URL diff --git a/scripts/project_native_cursor_cycle.py b/scripts/project_native_cursor_cycle.py new file mode 100644 index 000000000..7129a481a --- /dev/null +++ b/scripts/project_native_cursor_cycle.py @@ -0,0 +1,271 @@ +"""Closed Cursor MCP lifecycle bytes; this reader opens no files or children.""" +import base64 +import hashlib +import json +from pathlib import PurePosixPath +import re + +MODE = "cursor-complete-mcp-cycle" +MEMBERS = ("cursor-cycle-commands.jsonl", "cursor-cycle-proofs.jsonl", "cursor-cycle-probe.jsonl") +STAGES = tuple("git-a git-b version empty-a empty-b empty-outside add-a approve-a-one installed-a add-b approve-b-one installed-b installed-outside repeat-add-a unchanged-update-a intact-repair-a stable-a stable-b update-a approve-a-two updated-a updated-b updated-outside damaged-a missing-repair-a repaired-a remove-a removed-a remaining-b removed-outside repeat-remove-a remove-b final-a final-b final-outside".split()) +PROOFS = tuple("source-a-1.0.0 source-b-1.0.0 original-controls add-a approve-a-one add-b approve-b-one repeat-add-a unchanged-update-a intact-repair-a source-a-1.1.0 update-a approve-a-two missing-native-a missing-repair-a remove-a repeat-remove-a remove-b".split()) +TOOLS = frozenset("installed-a installed-b stable-a stable-b updated-a updated-b repaired-a remaining-b".split()) +PUBLIC = frozenset("add-a add-b repeat-add-a unchanged-update-a intact-repair-a update-a missing-repair-a remove-a repeat-remove-a remove-b".split()) +CHANGED = PUBLIC - {"repeat-add-a", "unchanged-update-a", "repeat-remove-a"} +FLAGS = tuple("project-lifecycle native-restart-removal mcp-initialize mcp-tools-list mcp-tools-call".split()) +EMPTY = b"No MCP servers configured (expected in .cursor/mcp.json or ~/.cursor/mcp.json)\n" +TOOL_TEXT = b"Tools for uapcursora (1):\n- inspect_runtime (marker, nonce, operation)\n" +ENABLE_TEXT = "✓ Enabled and approved MCP server: uapcursora\n".encode() +REFUSAL = b'agentplugins: plugin is not installed for target "cursor" in project scope; no target was changed\n' +CLEANUP = "full descendant supervision; forced cleanup fails" +SCOPE = "MCP-only: two project installations, fresh initialize/tools-list sessions; no tools-call, skills, runtime cwd/argv facts, vendor authentication or model" + + +def require(condition): + if not condition: + raise ValueError("Cursor complete MCP lifecycle evidence differs") + + +def obj(value, required, optional=""): + require(type(value) is dict and set(required.split()) <= set(value) <= set((required + " " + optional).split())) + + +def load(raw): + from project_native_runtime import strict_json + return strict_json(raw) + + +def private(raw): + from project_native_runtime import cline_cycle_private + return cline_cycle_private(raw, b"uap-cursor-private-") + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def compact(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + + +def same(a, b): + if type(a) is not type(b): + return False + if type(a) is dict: + return set(a) == set(b) and all(same(a[k], v) for k, v in b.items()) + if type(a) is list: + return len(a) == len(b) and all(same(x, y) for x, y in zip(a, b)) + return a == b + + +def canonical(path): + require(type(path) is str and path.startswith("/") and path != "/" and str(PurePosixPath(path)) == path and ".." not in PurePosixPath(path).parts and "\0" not in path) + return path + + +def stream(value): + obj(value, "bytes sha256 body") + require(type(value["bytes"]) is int and 0 <= value["bytes"] <= 1 << 20 and type(value["body"]) is str + and len(value["body"]) <= ((1 << 20) + 2) // 3 * 4 and type(value["sha256"]) is str) + raw = base64.b64decode(value["body"], validate=True) + require(len(raw) == value["bytes"] and sha(raw) == value["sha256"] and base64.b64encode(raw).decode() == value["body"] and not private(raw)) + raw.decode("utf-8", errors="strict") + return raw + + +def embedded(value): + if type(value) is dict: + if "body" in value: + stream(value) + else: + for item in value.values(): + embedded(item) + elif type(value) is list: + for item in value: + embedded(item) + + +def records(raw, order): + require(type(raw) is bytes and len(raw) <= 1 << 20 and (not raw or raw.endswith(b"\n")) and not private(raw)) + rows = [load(line) for line in raw.splitlines()] + require(len(rows) <= len(order)) + for row, stage in zip(rows, order): + obj(row, "stage value") + require(row["stage"] == stage and type(row["value"]) is dict) + if order == PROOFS and stage in ("approve-a-one", "approve-b-one", "approve-a-two"): + obj(row["value"], "scope path body fingerprints probe_bytes probe_sha256") + stream(row["value"]["body"]) + for key, item in row["value"].items(): + if key != "body": + embedded(item) + else: + embedded(row["value"]) + return rows + + +def stamp(value): + from project_native_copilot_events import instant + require(type(value) is str and re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,9})?Z", value)) + return instant(value) + + +def command(value, ordinary): + obj(value, "argv cwd exit error deadline elapsed_ms cleanup started_at ended_at stdout stderr") + require(type(value["argv"]) is list and value["argv"] and all(type(a) is str and "\0" not in a for a in value["argv"]) + and type(value["exit"]) is int and type(value["error"]) is bool and type(value["deadline"]) is bool + and type(value["elapsed_ms"]) is int and 0 <= value["elapsed_ms"] <= 35000 and value["cleanup"] == CLEANUP) + canonical(value["cwd"]) + start, end = stamp(value["started_at"]), stamp(value["ended_at"]) + duration = end-start + require(0 <= duration <= 35 * 10**9 and duration // 10**6 == value["elapsed_ms"]) + if ordinary: + require(value["error"] is False and value["deadline"] is False) + return stream(value["stdout"]), stream(value["stderr"]), start, end + + +def probe_delta(raw, nonce, start, end, seen): + require(raw and raw.endswith(b"\n") and len(raw) <= 1 << 20) + sessions, pids, last = {}, {}, start + for line in raw.splitlines(): + row = load(line) + obj(row, "method nonce pid session timestamp facts") + require(row["nonce"] == nonce and row["facts"] is None and type(row["pid"]) is int and row["pid"] > 0 + and type(row["session"]) is str and re.fullmatch(str(row["pid"]) + r"-[1-9][0-9]*", row["session"]) and row["session"] not in seen) + require(int(row["session"].split("-")[1]) < 1 << 64) + when = stamp(row["timestamp"]) + require(last <= when <= end) + last = when + session = row["session"] + if row["method"] == "initialize": + require(session not in sessions and row["pid"] not in pids) + sessions[session], pids[row["pid"]] = [row["pid"], 0], session + else: + require(row["method"] == "tools/list" and session in sessions and sessions[session][0] == row["pid"]) + sessions[session][1] += 1 + require(sessions and all(value[1] >= 1 for value in sessions.values())) + return set(sessions) + + +def pins(result, paths): + identity = result["acquisition"] + obj(identity, "client acquisition runtime_files") + require(identity["client"] == "cursor") + acq = identity["acquisition"] + obj(acq, "bytes installer_executed integrity members pin_verified resolved_origin sha256 url") + url = "https://downloads.cursor.com/lab/2026.10.22-27dcf5f/linux/x64/agent-cli-package.tar.gz" + require(acq["bytes"] == 184859176 and type(acq["bytes"]) is int and acq["installer_executed"] is False and acq["pin_verified"] is True + and acq["sha256"] == "85296944e4600a83c4b764f578d1502bf9c416db200d33a593e8f3ecabfdc294" + and acq["url"] == url and acq["resolved_origin"] == "https://downloads.cursor.com" + and acq["integrity"] == "sha256:" + acq["sha256"]) + rows = acq["members"] + require(type(rows) is list and len(rows) == 565 and sha(compact(rows)) == "0646a2eff536987e17b914607380879094058ac4f8c6b54d011cf5fe884d7b97") + files = {row["name"]: row["sha256"] for row in rows if row["kind"] == "file"} + root = str(PurePosixPath(paths["client"]).parent.parent) + require(paths["client"] == root + "/dist-package/cursor-agent" and len(files) == 445 + and same(identity["runtime_files"], [{"path": root + "/" + row["name"], "sha256": row["sha256"]} for row in rows if row["kind"] == "file"])) + require(same(result["scanner"], {"archive_sha256": "2b3d176db752433b904a4b42375543ff398f4841d22e48f7d4f23ded925b72da", "binary_sha256": "b6723451f155eb49e84acb34d1f06de929bddfcff6238cd4e38cdc6519396fba", "platform": "linux-amd64", "route": "prepopulated supported scanner cache; ordinary CLI security assessment", "source_tag": "v0.1.3", "version": "0.1.3"})) + + +def argv(stage, paths, proofs): + scope = "b" if stage.endswith("-b") or stage == "approve-b-one" else "a" + cwd = paths["outside"] if "outside" in stage or stage == "version" else paths[scope] + if stage.startswith("git-"): + return [paths["git"], "init", "--quiet", "--initial-branch=main"], cwd + if stage == "version": + return [paths["client"], "--version"], cwd + if stage in PUBLIC: + verb = "repair" if "repair" in stage else "update" if "update" in stage else "remove" if "remove" in stage else "add" + selector = paths["source_" + scope] if verb == "add" else proofs["add-" + scope]["installation"]["installation_id"] + return [paths["installer"], verb, selector] + (["--no-save"] if verb == "add" else []) + ["--scope", "project", "--project-root", cwd, "--target", "cursor", "--format", "json"], cwd + verb = "list-tools" if stage in TOOLS else "enable" if stage.startswith("approve-") else "list" + return [paths["client"], "mcp", verb] + (["uapcursora"] if verb != "list" else []), cwd + + +def progress(stage): + if stage in ("add-a", "add-b"): + return b"Resolving and validating one Agent Plugin package for every selected target...\nInstalling native-proof for 1 selected clients...\n" + if stage == "repeat-add-a": + return b"Resolving and validating one Agent Plugin package for every selected target...\nApplying transactional client package...\n" + if "update" in stage: + return b"Resolving and validating one updated Agent Plugin package for every selected target...\n" + if "repair" in stage: + return b"Resolving and validating each unique exact installed package revision once...\n" + return b"" + + +def complete(commands, proof_rows, probes, result): + from project_native_cursor_cycle_proof import validate, public_result + paths, proofs = validate(proof_rows, result, commands) + pins(result, paths) + prefix, sessions, elapsed, previous, groups = b"", set(), 0, None, set() + first = None + for frame, probe in zip(commands, probes): + stage, value = frame["stage"], frame["value"] + out, err, start, end = command(value, True) + if first is None: + first = start + require(previous is None or previous <= start) + previous, elapsed = end, elapsed + value["elapsed_ms"] + expected, cwd = argv(stage, paths, proofs) + require(value["argv"] == expected and value["cwd"] == cwd and value["exit"] == (1 if stage == "repeat-remove-a" else 0)) + if stage in PUBLIC: + if stage == "repeat-remove-a": + require(out == b"" and err == REFUSAL) + else: + require(err == progress(stage)) + group = public_result(out, stage, proofs[stage]) + if group: + require(group not in groups) + groups.add(group) + else: + require(err == b"") + expected_out = b"" if stage.startswith("git-") else b"2026.10.22-27dcf5f\n" if stage == "version" else TOOL_TEXT if stage in TOOLS else ENABLE_TEXT if stage.startswith("approve-") else EMPTY + require(out == expected_out) + require(probe["stage"] == stage) + value = probe["value"] + obj(value, "before_bytes before_sha256 after_bytes after_sha256 delta") + require(type(value["before_bytes"]) is int and value["before_bytes"] == len(prefix) and value["before_sha256"] == sha(prefix)) + delta = stream(value["delta"]) + if stage in TOOLS: + selected = "add-b" if stage.endswith("-b") else "update-a" if stage in ("updated-a", "repaired-a") else "add-a" + sessions |= probe_delta(delta, proofs[selected]["nonce"], start, end, sessions) + else: + require(delta == b"") + prefix += delta + require(len(prefix) <= 1 << 20 and type(value["after_bytes"]) is int and value["after_bytes"] == len(prefix) and value["after_sha256"] == sha(prefix)) + if stage.startswith("approve-"): + p = proofs[stage] + require(p["probe_bytes"] == len(prefix) and p["probe_sha256"] == sha(prefix)) + require(first is not None and previous - first < 150 * 10**9 and previous - first < (result["elapsed_ms"] + 1) * 10**6 + and elapsed <= result["elapsed_ms"] and same(result["cycle_probe"], {"bytes": len(prefix), "sha256": sha(prefix), "sessions": len(sessions)})) + + +def members(bodies, source): + try: + return admit(bodies, source) + except (KeyError, TypeError, AttributeError, IndexError, RecursionError, OverflowError, UnicodeError) as error: + raise ValueError("Cursor complete MCP structure refused") from error + + +def admit(bodies, source): + require(set(bodies) == {"runtime-attempt.json", *MEMBERS} and all(type(raw) is bytes and len(raw) <= 1 << 20 and not private(raw) for raw in bodies.values()) and sum(map(len, bodies.values())) <= 16 << 20) + result = load(bodies["runtime-attempt.json"]) + obj(result, "acquisition activation client elapsed_ms qualification real_model_qualified runtime_attempt_complete scanner source_identity test_failed vendor_authentication_qualified version capture_mode cycle_command_stages cycle_proof_stages cycle_observed_complete cycle_probe qualification_scope", "cycle_layout cycle_stage") + require(result["client"] == "cursor" and result["version"] == "2026.10.22-27dcf5f" and result["capture_mode"] == MODE and result["activation"] == "prepared" + and result["vendor_authentication_qualified"] is False and result["real_model_qualified"] is False and result["qualification_scope"] == SCOPE) + require(same(result["source_identity"], {"installer_base_commit": source["source_commit"], "installer_tree": source["source_tree"], "installer_source_state": "committed", "installer_patch_sha256": []})) + done = result["runtime_attempt_complete"] + require(type(done) is bool and type(result["test_failed"]) is bool and type(result["cycle_observed_complete"]) is bool and type(result["elapsed_ms"]) is int and 0 <= result["elapsed_ms"] <= 180000 + and same(result["qualification"], {flag: done and flag != "mcp-tools-call" for flag in FLAGS})) + commands, proofs, probes = [records(bodies[name], order) for name, order in zip(MEMBERS, (STAGES, PROOFS, STAGES))] + require(same(result["cycle_command_stages"], [r["stage"] for r in commands]) and same(result["cycle_proof_stages"], [r["stage"] for r in proofs]) and len(probes) <= len(commands)) + for row in commands: + command(row["value"], False) + for row in probes: + obj(row["value"], "before_bytes before_sha256 after_bytes after_sha256 delta") + if not done: + require(result["test_failed"] is True and result["cycle_observed_complete"] is False) + return result + require(result["test_failed"] is False and result["cycle_observed_complete"] is True and result["elapsed_ms"] < 150000 and len(commands) == len(probes) == len(STAGES) and len(proofs) == len(PROOFS)) + complete(commands, proofs, probes, result) + return result diff --git a/scripts/project_native_cursor_cycle_proof.py b/scripts/project_native_cursor_cycle_proof.py new file mode 100644 index 000000000..bbd881ec3 --- /dev/null +++ b/scripts/project_native_cursor_cycle_proof.py @@ -0,0 +1,251 @@ +"""Cursor-specific source, state, projection, approval and public DTO joins.""" +import json +import re + +from project_native_cursor_cycle import (CHANGED, compact, load, obj, require, same, sha, stamp, stream, canonical) + +SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" +MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" +NAMESPACE = "io.github.777genius.agentplugins/managed-stdio-v1/" + + +def digest(value): + require(type(value) is str and re.fullmatch(r"sha256:[0-9a-f]{64}", value)) + return value + + +def authored(paths, scope, version): + revision = scope + "-" + version + nonce = sha((paths["nonce"] + "\0" + revision).encode())[:32] + server = {"type": "stdio", "command": "./bin/probe", "args": ["cursor-component"], "cwd": "${PLUGIN_ROOT}", "env": {"UAP_TEST_ROOT": paths["root"], "UAP_TEST_EVENTS": paths["events"], "UAP_TEST_NONCE": nonce, "UAP_TEST_REVISION": revision}} + return {"plugin.json": compact({"$schema": SCHEMA, "name": "native-proof", "version": version}) + b"\n", "mcp.json": compact({"$schema": MCP_SCHEMA, "mcpServers": {"uapcursora": server}}) + b"\n"}, nonce, revision + + +def server(paths, selected): + active, data = selected["active"], selected["data"]["locator"] + return {"command": active + "/" + NAMESPACE + "agentplugins", "args": ["--internal-stdio-v1", active, data, active, "plugin", "--argv-base64-v1", "Li9iaW4vcHJvYmU=", "Y3Vyc29yLWNvbXBvbmVudA=="], "env": {"PLUGIN_ROOT": active, "PLUGIN_DATA": data, "UAP_TEST_ROOT": paths["root"], "UAP_TEST_EVENTS": paths["events"], "UAP_TEST_NONCE": selected["nonce"], "UAP_TEST_REVISION": selected["revision"]}} + + +def approval(paths, scope, selected): + raw = server(paths, selected) + # Exact pinned Zod shape order, followed by the source record's sorted keys. + parsed = {"command": raw["command"], "args": raw["args"], "env": dict(sorted(raw["env"].items()))} + encoded = json.dumps({"path": paths[scope], "server": parsed}, separators=(",", ":"), ensure_ascii=False).encode() + require(encoded.isascii() and b"${" not in encoded) + key = re.sub(r"[^a-zA-Z0-9]+", "-", paths[scope]).strip("-") + return paths["home"] + "/.cursor/projects/" + key + "/mcp-approvals.json", "uapcursora-" + sha(encoded)[:16] + + +def receipt(row, selected, group, count, before, after): + require(type(group) is str and re.fullmatch(r"op-[0-9a-f]{32}", group)) + op = group + "-001" + base = selected["active"].rsplit("/", 1)[0] + expected = {"operation_id": op, "operation_group_id": group, "sequence": count, "mutation_type": "directory_remove" if after is None else "directory_swap", "client_binding_id": selected["binding_id"], "active_path": selected["active"], "backup_path": base + "/.agentplugins-backup-" + sha(op.encode())[:16], "phase": "committed"} + if after is not None: + expected["staging_path"] = base + "/.agentplugins-staging-" + sha(op.encode())[:16] + expected["after_digest"] = digest(after) + if before: + expected["before_digest"] = digest(before) + require(same(row, expected)) + + +def payload(value, paths, selected, files): + obj(value, "root files digest") + require(value["root"] == selected["active"]) + digest(value["digest"]) + mcp = json.dumps({"mcpServers": {"uapcursora": server(paths, selected)}}, sort_keys=True, ensure_ascii=False, indent=2).encode() + b"\n" + manifest = json.dumps({"name": "native-proof", "version": selected["version"], "mcpServers": "./mcp.json"}, sort_keys=True, ensure_ascii=False, indent=2).encode() + b"\n" + metadata = json.dumps({"protocol": 1, "sha256": paths["installer_sha256"], "cliVersion": "0.1.0-development"}, separators=(",", ":")).encode() + fixed = {"plugin.json": files["plugin.json"], "mcp.json": mcp, ".cursor-plugin/plugin.json": manifest, NAMESPACE + "metadata.json": metadata} + require(type(value["files"]) is dict and set(value["files"]) == {*fixed, "bin/probe", NAMESPACE + "agentplugins"}) + for name, row in value["files"].items(): + obj(row, "bytes sha256 executable") + require(type(row["bytes"]) is int and row["bytes"] > 0 and type(row["executable"]) is bool and row["executable"] is (name in ("bin/probe", NAMESPACE + "agentplugins"))) + if name in fixed: + require(row["bytes"] == len(fixed[name]) and row["sha256"] == sha(fixed[name])) + else: + require(row["sha256"] == paths["probe_sha256" if name == "bin/probe" else "installer_sha256"]) + return value["digest"] + + +def installation(value, paths, previous, row, command): + scope, version = value["scope"], value["version"] + files, nonce, revision = authored(paths, scope, version) + require(value["nonce"] == nonce and value["revision"] == revision) + state_raw = stream(value["state"]) + state = load(state_raw) + obj(state, "schema_version installations", "transaction_receipts") + require(type(state["schema_version"]) is int and state["schema_version"] == 5 and type(state["installations"]) is list and len(state["installations"]) == 1) + i = state["installations"][0] + obj(i, "installation_id declared_name source package origin_mode operation_group_id data_receipts clients created_at updated_at", "data_retained") + iid = i["installation_id"] + require(type(iid) is str and re.fullmatch(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", iid) and i["declared_name"] == "native-proof" and i["origin_mode"] == "direct") + physical = "native-proof-" + sha(iid.encode())[:12] + active = paths[scope] + "/.uap/managed/clients/cursor/" + physical + data_id = "data_" + sha((iid + "\0" + physical + "\0project").encode())[:24] + binding_id = "client_" + sha((iid + "\0cursor\0project\0" + active).encode())[:24] + require(type(i["data_receipts"]) is dict and set(i["data_receipts"]) == {data_id}) + data = i["data_receipts"][data_id] + obj(data, "data_receipt_id physical_backend_id scope locator ownership_digest state created_at updated_at") + locator = paths[scope] + "/.uap/plugin-data/" + physical + for key, expected in {"data_receipt_id": data_id, "physical_backend_id": physical, "scope": "project", "locator": locator, "ownership_digest": "sha256:" + sha(("agentplugins-plugin-data-v1\0" + data_id + "\0" + locator).encode()), "state": "owned"}.items(): + require(data[key] == expected) + start, end = stamp(command["started_at"]), stamp(command["ended_at"]) + for key in ("created_at", "updated_at"): + require(stamp(i[key]) <= end and stamp(data[key]) <= end) + if previous is None: + require(start <= stamp(i["created_at"]) and start <= stamp(data["created_at"])) + else: + require(iid == previous["installation"]["installation_id"] and same(data, previous["data"]) and i["created_at"] == previous["installation"]["created_at"]) + src = paths["source_" + scope] + expected_source = {"source_binding_id": "src_" + sha((src + "\0\0").encode())[:24], "requested_source": src, "canonical_source": src, "resolved_revision": "", "tree_digest": value["source_digest"]} + expected_package = {"loader_kind": "agent_plugins", "format_id": "agent-plugins/1.0.0", "schema_uri": SCHEMA, "declared_name": "native-proof", "version": version, "manifest_digest": "sha256:" + sha(files["plugin.json"]), "inventory": {"mcp_present": True, "mcp_enabled": True, "mcp_servers": ["uapcursora"]}} + require(same(i["source"], expected_source) and same(i["package"], expected_package)) + selected = {**value, "installation": i, "data": data, "active": active, "binding_id": binding_id, "state_raw": state_raw} + obj(value["data"], "receipt owner caller") + require(same(value["data"]["receipt"], data) and same(load(stream(value["data"]["owner"])), data) + and stream(value["data"]["caller"]) == ("caller-data-" + scope + "-" + paths["nonce"] + "\n").encode()) + config = load(stream(value["native_config"])) + removed = value["removed"] + require(type(removed) is bool and same(config, {"mcpServers": {} if removed else {"uapcursora": server(paths, selected)}})) + if removed: + require(previous is not None and i["data_retained"] is True and i["clients"] == {} and len(state["transaction_receipts"]) == 1) + previous_binding = previous["binding"] + receipt(state["transaction_receipts"][0], selected, i["operation_group_id"], len(previous_binding["receipts"]) + 1, previous_binding["native_objects"][0]["managed_digest"], None) + selected["binding"] = previous_binding + else: + require("data_retained" not in i and "transaction_receipts" not in state and type(i["clients"]) is dict and set(i["clients"]) == {binding_id}) + b = i["clients"][binding_id] + obj(b, "client_binding_id client_id scope target_locator physical_artifact_id materialization activation authentication policy verification package_revision data_receipt_id affected_surfaces native_profile_root native_objects receipts updated_at") + package_digest = payload(value["payload"], paths, selected, files) + native_digest = "sha256:" + sha(b"agentplugins-native-mcp-v1\0mcpServers\0uapcursora\0" + compact(server(paths, selected))) + expected = {"client_binding_id": binding_id, "client_id": "cursor", "scope": "project", "target_locator": active, "physical_artifact_id": physical, "materialization": "materialized", "activation": "prepared", "authentication": "not_checked", "policy": "allowed", "verification": "installation_verified", "package_revision": {"version": version, "tree_digest": value["source_digest"], "manifest_digest": expected_package["manifest_digest"]}, "data_receipt_id": data_id, "affected_surfaces": ["cursor"], "native_profile_root": paths[scope] + "/.cursor"} + require(all(same(b.get(key), item) for key, item in expected.items())) + objects = [{"object_id": "package:cursor:" + physical, "kind": "managed_package_directory", "logical_name": "native-proof", "path": active, "managed_digest": package_digest, "protection_class": "managed"}, {"object_id": "cursor_project_mcp_server:uapcursora", "kind": "cursor_project_mcp_server", "logical_name": "uapcursora", "path": paths[scope] + "/.cursor/mcp.json", "managed_digest": native_digest, "protection_class": "managed"}] + require(same(b["native_objects"], objects)) + old_receipts = previous["binding"]["receipts"] if previous else [] + if row in CHANGED: + require(type(b["receipts"]) is list and len(b["receipts"]) == len(old_receipts) + 1 and same(b["receipts"][:-1], old_receipts) and start <= stamp(b["updated_at"]) <= end) + before = previous["binding"]["native_objects"][0]["managed_digest"] if previous else "" + receipt(b["receipts"][-1], selected, i["operation_group_id"], len(b["receipts"]), before, package_digest) + else: + require(previous and state_raw == previous["state_raw"]) + selected["binding"] = b + if row == "repeat-remove-a": + require(previous and state_raw == previous["state_raw"]) + return selected + + +def validate(rows, result, commands): + paths = result["cycle_layout"] + obj(paths, "root home a b outside source_a source_b nonce installer client git installer_sha256 probe_sha256") + root = canonical(paths["root"]) + expected_paths = {"home": root + "/home", "a": root + "/project", "b": root + "/project-B", "outside": root + "/outside", "source_a": root + "/package", "source_b": root + "/package-B"} + require(all(paths[key] == value for key, value in expected_paths.items()) and type(paths["nonce"]) is str and re.fullmatch(r"[0-9a-f]{32}", paths["nonce"])) + for key in ("installer", "client", "git"): + canonical(paths[key]) + require(len({paths[key] for key in ("installer", "client", "git")}) == 3 and all(re.fullmatch(r"[0-9a-f]{64}", paths[key]) for key in ("installer_sha256", "probe_sha256"))) + paths = {**paths, "events": root + "/events.jsonl"} + proofs, latest, sources, approval_ids = {}, {}, {}, {"a": [], "b": []} + commands = {row["stage"]: row["value"] for row in commands} + for row in rows: + stage, value = row["stage"], row["value"] + if stage.startswith("source-"): + obj(value, "scope source version revision nonce digest manifest mcp probe_sha256") + scope, version = value["scope"], value["version"] + require(stage == "source-" + scope + "-" + version and (scope, version) in (("a", "1.0.0"), ("b", "1.0.0"), ("a", "1.1.0"))) + files, nonce, revision = authored(paths, scope, version) + require(value["source"] == paths["source_" + scope] and value["nonce"] == nonce and value["revision"] == revision and stream(value["manifest"]) == files["plugin.json"] and stream(value["mcp"]) == files["mcp.json"] and value["probe_sha256"] == paths["probe_sha256"]) + sources[(scope, version)] = digest(value["digest"]) + elif stage == "original-controls": + obj(value, "user_config user_body foreign_user approval_controls") + controls = [] + for scope in ("a", "b", "outside"): + key = re.sub(r"[^a-zA-Z0-9]+", "-", paths[scope]).strip("-") + controls += [paths["home"] + "/.cursor/projects/" + key + "/" + name for name in ("mcp-approvals.json", "mcp-disabled.json")] + require(value["user_config"] == paths["home"] + "/.cursor/mcp.json" and stream(value["user_body"]) == b'{"mcpServers":{}}\n' and value["foreign_user"] == paths["home"] + "/foreign-owned.txt" and same(value["approval_controls"], controls)) + elif stage.startswith("approve-"): + obj(value, "scope path body fingerprints probe_bytes probe_sha256") + scope = "b" if stage == "approve-b-one" else "a" + path, identifier = approval(paths, scope, latest[scope]) + approval_ids[scope].append(identifier) + require(identifier not in approval_ids[scope][:-1] and value["scope"] == scope and value["path"] == path and same(value["fingerprints"], approval_ids[scope]) and stream(value["body"]) == json.dumps(approval_ids[scope], indent=2).encode()) + proofs[stage] = value + elif stage == "missing-native-a": + obj(value, "scope path before managed_package_unchanged") + require(value["scope"] == "a" and value["path"] == paths["a"] + "/.cursor/mcp.json" and value["managed_package_unchanged"] is True and same(load(stream(value["before"])), {"mcpServers": {"uapcursora": server(paths, latest["a"])}})) + else: + obj(value, "scope version nonce revision source_digest removed state native_config data", "payload") + scope = value["scope"] + expected_scope = "b" if stage in ("add-b", "remove-b") else "a" + expected_version = "1.1.0" if stage in ("update-a", "missing-repair-a", "remove-a", "repeat-remove-a") else "1.0.0" + require(scope == expected_scope and value["version"] == expected_version and value["source_digest"] == sources[(scope, value["version"])] and value["removed"] is (stage in ("remove-a", "repeat-remove-a", "remove-b")) and ("payload" in value) is (not value["removed"])) + prior = latest.get(scope) + selected = installation(value, paths, prior, stage, commands[stage]) + latest[scope], proofs[stage] = selected, selected + require(latest["a"]["removed"] and latest["b"]["removed"] and latest["a"]["installation"]["installation_id"] != latest["b"]["installation"]["installation_id"] and latest["a"]["data"]["data_receipt_id"] != latest["b"]["data"]["data_receipt_id"]) + require(approval_ids["a"] and len(approval_ids["a"]) == 2 and len(approval_ids["b"]) == 1) + require(sources[("a", "1.0.0")] != sources[("a", "1.1.0")]) + return paths, proofs + + +def public_result(raw, stage, selected): + row = load(raw) + obj(row, "schema_version command result data") + verb = "repair" if "repair" in stage else "update" if "update" in stage else "remove" if "remove" in stage else "add" + require(type(row["schema_version"]) is int and row["schema_version"] == 1 and row["command"] == verb and row["result"] == "success") + data, i = row["data"], selected["installation"] + if stage == "repeat-add-a": + output, group = data, "" + else: + obj(data, "operation_id batch status succeeded failed plugin dry_run targets", "version source tree_digest manifest_digest security action_required environment plugin_data_preserved data_retained retained_data retained_data_action") + require(same(data["batch"], True) and same(data["succeeded"], 1) and same(data["failed"], 0) and data["plugin"] == "native-proof" and data["dry_run"] is False and data["status"] == ("data_retained" if verb == "remove" else "completed")) + require(type(data["targets"]) is list and len(data["targets"]) == 1) + target = data["targets"][0] + obj(target, "target status output", "selected next_action") + require(target["target"] == "cursor" and target["status"] == "external_completed" and (verb != "update" or target["selected"] is True)) + output, group = target["output"], data["operation_id"] + require(type(group) is str and re.fullmatch(r"op-[0-9a-f]{32}", group) and output["operation_id"] == group and (stage not in CHANGED or group == i["operation_group_id"])) + if verb == "add": + require(same(data["environment"], {"reason": "no_save_requested", "saved": False})) + if verb == "remove": + require(data["data_retained"] is True and data["plugin_data_preserved"] is True and same(data["retained_data"], [{key: selected["data"][key] for key in ("data_receipt_id", "physical_backend_id", "scope", "state")}])) + obj(output, "plugin version source tree_digest manifest_digest dry_run result", "next_action security" + (" operation_id" if group else "")) + expected = {"plugin": "native-proof", "version": selected["version"], "source": "local" if verb in ("repair", "remove") else "direct local source", "tree_digest": selected["source_digest"], "manifest_digest": i["package"]["manifest_digest"], "dry_run": False} + require(all(same(output[key], value) for key, value in expected.items())) + def identities(value): + if type(value) is dict: + checks = {"installation_id": i["installation_id"], "tree_digest": selected["source_digest"], "manifest_digest": i["package"]["manifest_digest"], "client_id": "cursor", "target": "cursor", "scope": "project", "physical_artifact_id": selected["data"]["physical_backend_id"], "mutated": stage in CHANGED, "dry_run": False} + for key, item in value.items(): + if key in checks: + require(same(item, checks[key])) + if key == "no_change": + require(type(item) is bool and item is (stage in ("repeat-add-a", "unchanged-update-a"))) + identities(item) + elif type(value) is list: + for item in value: + identities(item) + identities(data) + outcome = output["result"] + require(outcome["installation_id"] == i["installation_id"] and outcome["requires_confirmation"] is False and outcome["mutated"] is (stage in CHANGED)) + if verb == "remove": + obj(outcome, "installation_id plugin client_id requires_confirmation mutated deactivation affected_surfaces group_phase") + require(outcome["plugin"] == "native-proof" and outcome["client_id"] == "cursor" and outcome["affected_surfaces"] == ["cursor"] and outcome["group_phase"] == "external_completed") + require(same(outcome["deactivation"], {"activation": "not_required", "artifact_removal_allowed": True, "external_removal_complete": True})) + else: + keys = "installation_id plan activation requires_confirmation mutated" + if group: + keys += " group_phase" + if stage in ("repeat-add-a", "unchanged-update-a"): + keys += " no_change" + obj(outcome, keys) + if group: + require(outcome["group_phase"] == "external_completed") + if stage in ("repeat-add-a", "unchanged-update-a"): + require(outcome["no_change"] is True) + plan, activation = outcome["plan"], outcome["activation"] + obj(plan, "client_id scope status package_mode activation authentication policy verification physical_artifact_id components", "user_actions warnings") + obj(activation, "activation authentication policy verification", "user_actions") + require(all(plan[key] == value for key, value in {"client_id": "cursor", "scope": "project", "status": "ready", "package_mode": "native", "activation": "prepared", "authentication": "not_checked", "policy": "allowed", "verification": "package_validated", "physical_artifact_id": selected["data"]["physical_backend_id"]}.items())) + require(same(plan["components"], [{"kind": "mcp_server", "name": "uapcursora", "support": "native"}]) and all(activation[key] == value for key, value in {"activation": "prepared", "authentication": "not_checked", "policy": "allowed", "verification": "installation_verified"}.items())) + return group diff --git a/scripts/project_native_research.py b/scripts/project_native_research.py index e710d119a..d81d562d3 100644 --- a/scripts/project_native_research.py +++ b/scripts/project_native_research.py @@ -145,27 +145,35 @@ def acquire(spec, budget, *, node=False): maximum = spec["max_bytes"] if type(maximum) is not int or not 1 <= maximum <= 512 << 20: raise ValueError("compressed archive cap refused") - # No ambient proxy or credential handlers; HTTPS redirects are checked before - # reading and TLS verification remains the Python default. - class HTTPSRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, request, fp, code, message, headers, newurl): - target = urllib.parse.urlsplit(newurl) - if target.scheme != "https" or target.username or target.password: - raise ValueError("unsafe acquisition redirect refused") - return super().redirect_request(request, fp, code, message, headers, newurl) - - opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), HTTPSRedirect()) - with opener.open(spec["url"], timeout=min(90, budget.check())) as response: - if urllib.parse.urlsplit(response.url).scheme != "https": - raise ValueError("non-HTTPS acquisition redirect refused") - body = bounded_read(BudgetReader(response, budget), maximum) + transport = spec.get("transport") + if transport is not None: + if node or transport != "cursor-publisher-curl": + raise ValueError("unregistered acquisition transport") + from project_native_cursor_acquisition import download + body, resolved_url = download(spec, budget) + else: + # No ambient proxy or credential handlers; HTTPS redirects are checked before + # reading and TLS verification remains the Python default. + class HTTPSRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, message, headers, newurl): + target = urllib.parse.urlsplit(newurl) + if target.scheme != "https" or target.username or target.password: + raise ValueError("unsafe acquisition redirect refused") + return super().redirect_request(request, fp, code, message, headers, newurl) + + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), HTTPSRedirect()) + with opener.open(spec["url"], timeout=min(90, budget.check())) as response: + if urllib.parse.urlsplit(response.url).scheme != "https": + raise ValueError("non-HTTPS acquisition redirect refused") + body = bounded_read(BudgetReader(response, budget), maximum) + resolved_url = response.url if spec.get("expected_bytes") is not None and len(body) != spec["expected_bytes"]: raise ValueError("archive size differs from reviewed pin") pin = spec.get("integrity") if pin is not None: native.verify_digest(body, pin) entries, contents = inventory_archive(body, spec, budget) - resolved = urllib.parse.urlsplit(response.url) + resolved = urllib.parse.urlsplit(resolved_url) return {"url": spec["url"], "resolved_origin": resolved.scheme + "://" + resolved.netloc, "bytes": len(body), "sha256": hashlib.sha256(body).hexdigest(), "integrity": pin, "pin_verified": pin is not None, "members": entries, "installer_executed": False}, contents diff --git a/scripts/project_native_runtime.py b/scripts/project_native_runtime.py index 36c5d8d4f..9567f5a28 100644 --- a/scripts/project_native_runtime.py +++ b/scripts/project_native_runtime.py @@ -26,6 +26,9 @@ SOURCE = shared.SOURCE CASES = { + "cursor": {"test": "TestAgentpluginsCursorNativeProjectCompleteMCPCycle", + "file": "repotests/agentplugins_cursor_cycle_finish_test.go", + "optin": "AGENTPLUGINS_CURSOR_PROJECT_RUNTIME_ATTEMPT", "seconds": 180, "phase": "runtime-attempt"}, "cline": {"test": "TestAgentpluginsClineNativeProjectCompleteCycle", "file": "repotests/agentplugins_cline_cycle_lifecycle_test.go", "optin": "AGENTPLUGINS_CLINE_PROJECT_RUNTIME_ATTEMPT", "seconds": 180, @@ -59,7 +62,7 @@ "file": "repotests/agentplugins_windsurf_project_native_attempt_test.go", "optin": "AGENTPLUGINS_WINDSURF_PROJECT_RUNTIME_ATTEMPT", "seconds": 180, "phase": "runtime-attempt"}, } -CLI_ATTEMPTS = {"qoder": "1.1.78", "grok": "1.0.70", "kiro": "2.34.0", "windsurf": "3000.11.205"} +CLI_ATTEMPTS = {"cursor": "2026.10.22-27dcf5f", "qoder": "1.1.78", "grok": "1.0.70", "kiro": "2.34.0", "windsurf": "3000.11.205"} MAX_ZIP = 32 << 20 MAX_MEMBER = 2 << 20 COMMANDS = ("git-A", "git-B", "version", "add-A", "native-attempt") @@ -709,6 +712,8 @@ def cli_attempt_members(bodies, source, client): from project_native_grok_cycle import members elif (client, summary["capture_mode"]) == ("qoder", "qoder-complete-skills-cycle"): from project_native_qoder_cycle import members + elif (client, summary["capture_mode"]) == ("cursor", "cursor-complete-mcp-cycle"): + from project_native_cursor_cycle import members else: raise ValueError("unknown fixed native CLI capture mode") return members(bodies, source) @@ -721,6 +726,9 @@ def cli_attempt_members(bodies, source, client): commands += ("native-catalog",) elif client == "grok": commands += ("native-inspect",) + elif client == "cursor": + commands += ("empty-a-mcp", "empty-b-mcp", "empty-outside-mcp", "control-b-mcp", "control-outside-mcp", + "approve-a-mcp", "native-tools-a") for label in commands: triple = {label + suffix for suffix in (".command.json", ".stdout.log", ".stderr.log")} allowed |= triple @@ -2214,6 +2222,29 @@ def query(label): return result +def cursor_admission_refusal(error): + """Emit only a bounded owned-source callsite; never format error data.""" + owned = {str(SOURCE / "scripts" / name): name for name in ( + "project_native_cursor_cycle.py", "project_native_cursor_cycle_proof.py", "project_native_runtime.py")} + location = "unavailable" + for candidate in (error.__cause__, error): + if not isinstance(candidate, BaseException): + continue + trace, selected = candidate.__traceback__, None + for _ in range(128): + if trace is None: + break + code = trace.tb_frame.f_code + label = owned.get(code.co_filename) + if label is not None and code.co_name not in ("require", "obj") and 0 < trace.tb_lineno <= 999999: + selected = label + ":" + str(trace.tb_lineno) + trace = trace.tb_next + if trace is None and selected is not None: + location = selected + break + print("Cursor native admission refused at " + location, file=sys.stderr, flush=True) + + def run(client, output): shared.native.require_hosted("linux-amd64") if client == "cline": @@ -2273,6 +2304,8 @@ def accept_test(record, stdout): except Exception as error: summary["runtime_admission_error"] = type(error).__name__ summary["test_passed"] = False + if client == "cursor": + cursor_admission_refusal(error) summary["collection_complete"] = bool(summary["test_passed"] and summary["runtime_artifact_admitted"]) shared.publish(summary, ctx, output) (output / "upload/research.zip").rename(output / "upload/frontend.zip") diff --git a/scripts/test_project_native_cursor.py b/scripts/test_project_native_cursor.py new file mode 100644 index 000000000..e1b2a0fd4 --- /dev/null +++ b/scripts/test_project_native_cursor.py @@ -0,0 +1,125 @@ +"""Inert contracts for the Cursor acquisition boundary; no vendor execution.""" + +import importlib.util +from pathlib import Path +import subprocess +import unittest +from unittest.mock import patch +from urllib.parse import urlsplit +import urllib.request + + +LEAF = Path(__file__).with_name("project_native_collectors") / "cursor.py" + + +def load_leaf(): + spec = importlib.util.spec_from_file_location("cursor_acquisition_contract", LEAF) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class ForbiddenContext: + def __init__(self, phase): + object.__setattr__(self, "phase", phase) + + def __getattribute__(self, name): + if name == "phase": + return object.__getattribute__(self, name) + raise AssertionError("unreviewed phase accessed native context: " + name) + + +class CursorAcquisitionContract(unittest.TestCase): + def test_import_and_unreviewed_phases_never_start_a_process_or_download(self): + with ( + patch.object(subprocess, "Popen", side_effect=AssertionError("process execution")) as process, + patch.object(urllib.request, "urlopen", side_effect=AssertionError("network acquisition")) as download, + ): + leaf = load_leaf() + for context in (None, ForbiddenContext("acquire"), ForbiddenContext("observe"), ForbiddenContext("unknown")): + with self.subTest(context_type=type(context).__name__): + with self.assertRaisesRegex(ValueError, "only its reviewed help phase"): + leaf.collect(context) + process.assert_not_called() + download.assert_not_called() + + def test_measured_pin_limits_native_execution_to_reviewed_help(self): + leaf = load_leaf() + self.assertEqual(leaf.AVAILABLE_PHASES, ("acquire", "help")) + self.assertEqual(leaf.ACQUISITION["integrity"], "sha256:85296944e4600a83c4b764f578d1502bf9c416db200d33a593e8f3ecabfdc294") + self.assertEqual(leaf.ACQUISITION["entrypoint"], "dist-package/cursor-agent") + self.assertEqual(leaf.ACQUISITION["binary_sha256"], "2ccc9a8e167797641448b5e5c936f006ba137a2555f117f38c5eb76a5238a233") + self.assertEqual(leaf.ACQUISITION["expected_bytes"], 184859176) + self.assertEqual(leaf.ACQUISITION["max_expanded_bytes"], 640 << 20) + self.assertEqual(leaf.ACQUISITION["transport"], "cursor-publisher-curl") + with self.assertRaisesRegex(ValueError, "only its reviewed help phase"): + leaf.collect(ForbiddenContext("observe")) + + def test_acquisition_is_bounded_and_uses_an_immutable_official_candidate(self): + leaf = load_leaf() + candidate = leaf.ACQUISITION + url = urlsplit(candidate["url"]) + self.assertEqual(url.scheme, "https") + self.assertEqual(url.netloc, "downloads.cursor.com") + self.assertFalse(url.query or url.fragment or url.username or url.password) + self.assertRegex(leaf.VERSION, r"\A\d{4}\.\d{2}\.\d{2}-[0-9a-f]{7}\Z") + self.assertEqual( + url.path.split("/"), + ["", "lab", leaf.VERSION, "linux", "x64", "agent-cli-package.tar.gz"], + ) + self.assertIs(type(candidate["max_bytes"]), int) + self.assertGreater(candidate["max_bytes"], 0) + self.assertLessEqual(candidate["max_bytes"], 256 * 1024 * 1024) + self.assertEqual(candidate["format"], "tar.gz") + self.assertEqual(candidate["runtime"], "native") + + def test_closed_probe_table_preserves_raw_component_capture_without_scope_claim(self): + leaf = load_leaf() + calls = [] + class Context: + phase = "help" + def probe(self, label, arguments, **options): + calls.append((label, arguments, options)) + body = (leaf.VERSION + "\n").encode() if label == "cursor-version" else b"opaque native text\n" + return {"exit": 0, "error": None}, body, b"" + result = leaf.collect(Context()) + expected = [ + ("cursor-version", ["--version"], {"project": "a", "timeout": 30}), + ("cursor-help", ["--help"], {"project": "a", "timeout": 30}), + ("cursor-mcp-help", ["mcp", "--help"], {"project": "a", "timeout": 30}), + ("cursor-plugin-help", ["plugin", "--help"], {"project": "a", "timeout": 30}), + ("cursor-empty-a-mcp", ["mcp", "list"], {"project": "a", "timeout": 30}), + ("cursor-empty-b-mcp", ["mcp", "list"], {"project": "b", "timeout": 30}), + ("cursor-empty-outside-mcp", ["mcp", "list"], {"project": "user", "timeout": 30}), + ] + self.assertEqual(calls, expected) + self.assertEqual([row["id"] for row in result["cases"]], ["cursor.help"]) + self.assertEqual(len(result["observations"]), 7) + for case in ("scope-observation", "project-lifecycle", "native-restart-removal", + "mcp-initialize", "mcp-tools-list", "mcp-tools-call", "vendor-authentication", "real-model"): + self.assertIn("cursor." + case, result["unobserved"]) + + def test_unknown_version_process_failure_empty_help_and_non_utf8_fail_closed(self): + leaf = load_leaf() + variants = (("cursor-version", {"exit": 0, "error": None}, b"other\n", b""), + ("cursor-version", {"exit": 0, "error": None}, (leaf.VERSION + "\n").encode(), b"unexpected"), + ("cursor-help", {"exit": 1, "error": None}, b"help", b""), + ("cursor-help", {"exit": 0, "error": "deadline"}, b"help", b""), + ("cursor-help", {"exit": False, "error": None}, b"help", b""), + ("cursor-help", {"exit": 0, "error": None}, b" \n", b""), + ("cursor-help", {"exit": 0, "error": None}, b"\xff", b""), + ("cursor-empty-a-mcp", {"exit": 0, "error": None}, b"raw", b"\xff")) + for label, record, stdout, stderr in variants: + class Context: + phase = "help" + def probe(self, current, arguments, **options): + if current == label: + return record, stdout, stderr + body = (leaf.VERSION + "\n").encode() if current == "cursor-version" else b"opaque\n" + return {"exit": 0, "error": None}, body, b"" + with self.subTest(label=label, stdout=stdout), self.assertRaises(ValueError): + leaf.collect(Context()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_project_native_cursor_cycle.py b/scripts/test_project_native_cursor_cycle.py new file mode 100644 index 000000000..fec7a37ed --- /dev/null +++ b/scripts/test_project_native_cursor_cycle.py @@ -0,0 +1,362 @@ +"""Inert Cursor stage/state/protocol joins; no vendor process or credential.""" +import base64 +import copy +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +import project_native_cursor_cycle as cycle +import project_native_runtime as runtime + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def wire(raw): + return {"bytes": len(raw), "sha256": sha(raw), "body": base64.b64encode(raw).decode()} + + +def compact(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + + +def synthetic_cycle(): + # Independent literal recipe. Pin/archive validation has its own refusal + # contract; this synthetic fixture isolates the public/state/probe joins. + order = "git-a git-b version empty-a empty-b empty-outside add-a approve-a-one installed-a add-b approve-b-one installed-b installed-outside repeat-add-a unchanged-update-a intact-repair-a stable-a stable-b update-a approve-a-two updated-a updated-b updated-outside damaged-a missing-repair-a repaired-a remove-a removed-a remaining-b removed-outside repeat-remove-a remove-b final-a final-b final-outside".split() + p = {"root": "/owned", "home": "/owned/home", "a": "/owned/project", "b": "/owned/project-B", "outside": "/owned/outside", "source_a": "/owned/package", "source_b": "/owned/package-B", "nonce": "1" * 32, "installer": "/held/uap", "client": "/pin/dist-package/cursor-agent", "git": "/usr/bin/git", "installer_sha256": sha(b"inert-installer"), "probe_sha256": sha(b"inert-probe")} + commands, proofs, probes, latest, approval_ids = [], [], [], {}, {"a": [], "b": []} + prefix, session_count = b"", 0 + schema = "https://agent-plugins.org/schemas/1.0.0/" + namespace = "io.github.777genius.agentplugins/managed-stdio-v1/" + def proof(stage, value): + proofs.append({"stage": stage, "value": value}) + def source(scope, version): + revision = scope + "-" + version + nonce = sha((p["nonce"] + "\0" + revision).encode())[:32] + manifest = compact({"$schema": schema + "plugin.schema.json", "name": "native-proof", "version": version}) + b"\n" + config = compact({"$schema": schema + "mcp.schema.json", "mcpServers": {"uapcursora": {"type": "stdio", "command": "./bin/probe", "args": ["cursor-component"], "cwd": "${PLUGIN_ROOT}", "env": {"UAP_TEST_ROOT": p["root"], "UAP_TEST_EVENTS": "/owned/events.jsonl", "UAP_TEST_NONCE": nonce, "UAP_TEST_REVISION": revision}}}}) + b"\n" + value = {"scope": scope, "source": p["source_" + scope], "version": version, "revision": revision, "nonce": nonce, "digest": "sha256:" + sha(manifest + config), "manifest": wire(manifest), "mcp": wire(config), "probe_sha256": p["probe_sha256"]} + proof("source-" + scope + "-" + version, value) + return value, manifest + sources = {scope: source(scope, "1.0.0") for scope in ("a", "b")} + stores = {} + for scope in ("a", "b", "outside"): + key = p[scope].strip("/").replace("/", "-") + stores[scope] = p["home"] + "/.cursor/projects/" + key + "/" + proof("original-controls", {"user_config": "/owned/home/.cursor/mcp.json", "user_body": wire(b'{"mcpServers":{}}\n'), "foreign_user": "/owned/home/foreign-owned.txt", "approval_controls": [stores[s] + n for s in ("a", "b", "outside") for n in ("mcp-approvals.json", "mcp-disabled.json")]}) + def server(selected): + return {"command": selected["active"] + "/" + namespace + "agentplugins", "args": ["--internal-stdio-v1", selected["active"], selected["data"]["locator"], selected["active"], "plugin", "--argv-base64-v1", "Li9iaW4vcHJvYmU=", "Y3Vyc29yLWNvbXBvbmVudA=="], "env": {"PLUGIN_ROOT": selected["active"], "PLUGIN_DATA": selected["data"]["locator"], "UAP_TEST_ROOT": "/owned", "UAP_TEST_EVENTS": "/owned/events.jsonl", "UAP_TEST_NONCE": selected["nonce"], "UAP_TEST_REVISION": selected["revision"]}} + for index, stage in enumerate(order): + scope = "b" if stage.endswith("-b") or stage == "approve-b-one" else "a" + start, end = f"2026-10-29T22:00:{index:02d}Z", f"2026-10-29T22:00:{index:02d}.5Z" + if stage == "update-a": + sources["a"] = source("a", "1.1.0") + if stage == "damaged-a": + proof("missing-native-a", {"scope": "a", "path": p["a"] + "/.cursor/mcp.json", "before": wire(compact({"mcpServers": {"uapcursora": server(latest["a"])}})), "managed_package_unchanged": True}) + out, err, delta = b"", b"", b"" + cwd = p["outside"] if stage == "version" or "outside" in stage else p[scope] + if stage.startswith("git-"): + argv = [p["git"], "init", "--quiet", "--initial-branch=main"] + elif stage == "version": + argv, out = [p["client"], "--version"], b"2026.10.22-27dcf5f\n" + elif stage in {"add-a", "add-b", "repeat-add-a", "unchanged-update-a", "intact-repair-a", "update-a", "missing-repair-a", "remove-a", "repeat-remove-a", "remove-b"}: + src, manifest = sources[scope] + version = src["version"] + old = latest.get(scope) + changed = stage not in ("repeat-add-a", "unchanged-update-a", "repeat-remove-a") + removed = stage in ("remove-a", "remove-b", "repeat-remove-a") + iid = old["installation"]["installation_id"] if old else ("a" if scope == "a" else "b") * 8 + "-1111-2222-3333-444444444444" + physical = "native-proof-" + sha(iid.encode())[:12] + active = p[scope] + "/.uap/managed/clients/cursor/" + physical + did = "data_" + sha((iid + "\0" + physical + "\0project").encode())[:24] + bid = "client_" + sha((iid + "\0cursor\0project\0" + active).encode())[:24] + locator = p[scope] + "/.uap/plugin-data/" + physical + data = old["data"] if old else {"data_receipt_id": did, "physical_backend_id": physical, "scope": "project", "locator": locator, "ownership_digest": "sha256:" + sha(("agentplugins-plugin-data-v1\0" + did + "\0" + locator).encode()), "state": "owned", "created_at": start, "updated_at": start} + group = "op-" + f"{index:032x}" + selected = {"scope": scope, "version": version, "nonce": src["nonce"], "revision": src["revision"], "source_digest": src["digest"], "removed": removed, "active": active, "data": data} + native = {"mcpServers": {} if removed else {"uapcursora": server(selected)}} + native_raw = compact(native) + pack = {"loader_kind": "agent_plugins", "format_id": "agent-plugins/1.0.0", "schema_uri": schema + "plugin.schema.json", "declared_name": "native-proof", "version": version, "manifest_digest": "sha256:" + sha(manifest), "inventory": {"mcp_present": True, "mcp_enabled": True, "mcp_servers": ["uapcursora"]}} + i = {"installation_id": iid, "declared_name": "native-proof", "source": {"source_binding_id": "src_" + sha((p["source_" + scope] + "\0\0").encode())[:24], "requested_source": p["source_" + scope], "canonical_source": p["source_" + scope], "resolved_revision": "", "tree_digest": src["digest"]}, "package": pack, "origin_mode": "direct", "operation_group_id": group, "data_receipts": {did: data}, "clients": {}, "created_at": old["installation"]["created_at"] if old else start, "updated_at": start} + state = {"schema_version": 5, "installations": [i]} + old_binding = old["binding"] if old else None + if changed: + op = group + "-001" + base = active.rsplit("/", 1)[0] + receipt = {"operation_id": op, "operation_group_id": group, "sequence": len(old_binding["receipts"]) + 1 if old_binding else 1, "mutation_type": "directory_remove" if removed else "directory_swap", "client_binding_id": bid, "active_path": active, "backup_path": base + "/.agentplugins-backup-" + sha(op.encode())[:16], "phase": "committed"} + if old_binding: + receipt["before_digest"] = old_binding["native_objects"][0]["managed_digest"] + if removed: + i["data_retained"] = True + state["transaction_receipts"] = [receipt] if changed else old["state"]["transaction_receipts"] + binding = old_binding + else: + package_digest = "sha256:" + sha((scope + version).encode()) + if changed: + receipt["staging_path"], receipt["after_digest"] = base + "/.agentplugins-staging-" + sha(op.encode())[:16], package_digest + binding = {"client_binding_id": bid, "client_id": "cursor", "scope": "project", "target_locator": active, "physical_artifact_id": physical, "materialization": "materialized", "activation": "prepared", "authentication": "not_checked", "policy": "allowed", "verification": "installation_verified", "package_revision": {"version": version, "tree_digest": src["digest"], "manifest_digest": pack["manifest_digest"]}, "data_receipt_id": did, "affected_surfaces": ["cursor"], "native_profile_root": p[scope] + "/.cursor", "native_objects": [{"object_id": "package:cursor:" + physical, "kind": "managed_package_directory", "logical_name": "native-proof", "path": active, "managed_digest": package_digest, "protection_class": "managed"}, {"object_id": "cursor_project_mcp_server:uapcursora", "kind": "cursor_project_mcp_server", "logical_name": "uapcursora", "path": p[scope] + "/.cursor/mcp.json", "managed_digest": "sha256:" + sha(b"agentplugins-native-mcp-v1\0mcpServers\0uapcursora\0" + compact(server(selected))), "protection_class": "managed"}], "receipts": (old_binding["receipts"] if old_binding else []) + ([receipt] if changed else []), "updated_at": start} + i["clients"] = {bid: binding} + if not changed: + state, i, binding = copy.deepcopy(old["state"]), copy.deepcopy(old["installation"]), copy.deepcopy(old_binding) + value = {key: selected[key] for key in ("scope", "version", "nonce", "revision", "source_digest", "removed")} + value.update(state=wire(compact(state)), native_config=wire(native_raw), data={"receipt": data, "owner": wire(compact(data)), "caller": wire(("caller-data-" + scope + "-" + p["nonce"] + "\n").encode())}) + if not removed: + metadata = json.dumps({"protocol": 1, "sha256": p["installer_sha256"], "cliVersion": "0.1.0-development"}, separators=(",", ":")).encode() + fixed = {"plugin.json": manifest, "mcp.json": json.dumps(native, sort_keys=True, ensure_ascii=False, indent=2).encode() + b"\n", ".cursor-plugin/plugin.json": json.dumps({"name": "native-proof", "version": version, "mcpServers": "./mcp.json"}, sort_keys=True, indent=2).encode() + b"\n", namespace + "metadata.json": metadata, "bin/probe": b"inert-probe", namespace + "agentplugins": b"inert-installer"} + value["payload"] = {"root": active, "digest": package_digest, "files": {name: {"bytes": len(raw), "sha256": sha(raw), "executable": name in ("bin/probe", namespace + "agentplugins")} for name, raw in fixed.items()}} + proof(stage, value) + latest[scope] = {**selected, "installation": i, "state": state, "binding": binding} + verb = "repair" if "repair" in stage else "update" if "update" in stage else "remove" if "remove" in stage else "add" + argv = [p["installer"], verb, p["source_" + scope] if verb == "add" else iid] + (["--no-save"] if verb == "add" else []) + ["--scope", "project", "--project-root", p[scope], "--target", "cursor", "--format", "json"] + if stage == "repeat-remove-a": + err = b'agentplugins: plugin is not installed for target "cursor" in project scope; no target was changed\n' + else: + outcome = {"installation_id": iid, "requires_confirmation": False, "mutated": changed} + if removed: + outcome.update(plugin="native-proof", client_id="cursor", affected_surfaces=["cursor"], group_phase="external_completed", deactivation={"activation": "not_required", "artifact_removal_allowed": True, "external_removal_complete": True}) + else: + outcome.update(plan={"client_id": "cursor", "scope": "project", "status": "ready", "package_mode": "native", "activation": "prepared", "authentication": "not_checked", "policy": "allowed", "verification": "package_validated", "physical_artifact_id": physical, "components": [{"kind": "mcp_server", "name": "uapcursora", "support": "native"}]}, activation={"activation": "prepared", "authentication": "not_checked", "policy": "allowed", "verification": "installation_verified"}) + if stage != "repeat-add-a": + outcome["group_phase"] = "external_completed" + if stage in ("repeat-add-a", "unchanged-update-a"): + outcome["no_change"] = True + output = {"plugin": "native-proof", "version": version, "source": "local" if verb in ("repair", "remove") else "direct local source", "tree_digest": src["digest"], "manifest_digest": pack["manifest_digest"], "dry_run": False, "result": outcome} + data_out = output + if stage != "repeat-add-a": + output["operation_id"] = group + target = {"target": "cursor", "status": "external_completed", "output": output} + if verb == "update": + target["selected"] = True + data_out = {"operation_id": group, "batch": True, "status": "data_retained" if removed else "completed", "succeeded": 1, "failed": 0, "plugin": "native-proof", "dry_run": False, "targets": [target]} + if verb == "add": + data_out["environment"] = {"reason": "no_save_requested", "saved": False} + if removed: + data_out.update(plugin_data_preserved=True, data_retained=True, retained_data=[{k: data[k] for k in ("data_receipt_id", "physical_backend_id", "scope", "state")}]) + out = compact({"schema_version": 1, "command": verb, "result": "success", "data": data_out}) + if stage in ("add-a", "add-b"): + err = b"Resolving and validating one Agent Plugin package for every selected target...\nInstalling native-proof for 1 selected clients...\n" + elif stage == "repeat-add-a": + err = b"Resolving and validating one Agent Plugin package for every selected target...\nApplying transactional client package...\n" + elif "update" in stage: + err = b"Resolving and validating one updated Agent Plugin package for every selected target...\n" + elif "repair" in stage: + err = b"Resolving and validating each unique exact installed package revision once...\n" + else: + verb = "enable" if stage.startswith("approve-") else "list-tools" if stage in {"installed-a", "installed-b", "stable-a", "stable-b", "updated-a", "updated-b", "repaired-a", "remaining-b"} else "list" + argv = [p["client"], "mcp", verb] + (["uapcursora"] if verb != "list" else []) + if verb == "enable": + selected = latest[scope] + raw = server(selected) + parsed = {"command": raw["command"], "args": raw["args"], "env": dict(sorted(raw["env"].items()))} + fingerprint = "uapcursora-" + sha(json.dumps({"path": p[scope], "server": parsed}, separators=(",", ":")).encode())[:16] + approval_ids[scope].append(fingerprint) + proof(stage, {"scope": scope, "path": stores[scope] + "mcp-approvals.json", "body": wire(json.dumps(approval_ids[scope], indent=2).encode()), "fingerprints": list(approval_ids[scope]), "probe_bytes": len(prefix), "probe_sha256": sha(prefix)}) + out = "✓ Enabled and approved MCP server: uapcursora\n".encode() + elif verb == "list-tools": + session_count += 1 + pid = 100 + session_count + delta = b"".join(compact({"method": method, "pid": pid, "session": f"{pid}-{1000+pid}", "nonce": latest[scope]["nonce"], "timestamp": start, "facts": None}) + b"\n" for method in ("initialize", "tools/list", "tools/list")) + out = b"Tools for uapcursora (1):\n- inspect_runtime (marker, nonce, operation)\n" + else: + out = b"No MCP servers configured (expected in .cursor/mcp.json or ~/.cursor/mcp.json)\n" + commands.append({"stage": stage, "value": {"argv": argv, "cwd": cwd, "exit": 1 if stage == "repeat-remove-a" else 0, "error": False, "deadline": False, "elapsed_ms": 500, "cleanup": "full descendant supervision; forced cleanup fails", "started_at": start, "ended_at": end, "stdout": wire(out), "stderr": wire(err)}}) + probe = {"before_bytes": len(prefix), "before_sha256": sha(prefix), "delta": wire(delta)} + prefix += delta + probe.update(after_bytes=len(prefix), after_sha256=sha(prefix)) + probes.append({"stage": stage, "value": probe}) + result = {"cycle_layout": p, "cycle_probe": {"bytes": len(prefix), "sha256": sha(prefix), "sessions": session_count}, "elapsed_ms": 35000} + return commands, proofs, probes, result + + +class CursorCycleContracts(unittest.TestCase): + def test_failed_prefix_stays_false_and_cannot_forge_completion(self): + source = {"source_commit": "a" * 40, "source_tree": "b" * 40} + summary = {"client": "cursor", "version": "2026.10.22-27dcf5f", "capture_mode": cycle.MODE, "activation": "prepared", "runtime_attempt_complete": False, "test_failed": True, "cycle_observed_complete": False, "vendor_authentication_qualified": False, "real_model_qualified": False, "qualification": {key: False for key in cycle.FLAGS}, "qualification_scope": cycle.SCOPE, "source_identity": {"installer_base_commit": "a" * 40, "installer_tree": "b" * 40, "installer_source_state": "committed", "installer_patch_sha256": []}, "acquisition": {}, "scanner": {}, "elapsed_ms": 0, "cycle_command_stages": [], "cycle_proof_stages": [], "cycle_probe": {"bytes": 0, "sha256": sha(b""), "sessions": 0}} + bodies = {name: b"" for name in cycle.MEMBERS} + bodies["runtime-attempt.json"] = compact(summary) + self.assertEqual(cycle.members(bodies, source), summary) + for change in ({"runtime_attempt_complete": True, "test_failed": False, "cycle_observed_complete": True, "qualification": {key: key != "mcp-tools-call" for key in cycle.FLAGS}}, {"qualification": {key: True for key in cycle.FLAGS}}, {"vendor_authentication_qualified": True}, {"unknown": True}, {"capture_mode": "cursor-anything"}, {"cycle_command_stages": ["version"]}): + with self.assertRaises(ValueError): + cycle.members({**bodies, "runtime-attempt.json": compact({**summary, **change})}, source) + with self.assertRaises(ValueError): + cycle.members({**bodies, "unknown.log": b""}, source) + + def test_probe_validates_every_session_row_and_refuses_calls(self): + start, end = cycle.stamp("2026-10-29T22:00:00Z"), cycle.stamp("2026-10-29T22:00:01Z") + def event(method, pid=7, **changes): + return {"method": method, "nonce": "held", "pid": pid, "session": str(pid) + "-100", "timestamp": "2026-10-29T22:00:00Z", "facts": None, **changes} + def raw(rows): + return b"".join(compact(row) + b"\n" for row in rows) + rows = [event("initialize"), event("tools/list"), event("initialize", 8), event("tools/list", 8), event("tools/list")] + self.assertEqual(cycle.probe_delta(raw(rows), "held", start, end, set()), {"7-100", "8-100"}) + hostile = [[event("tools/list")], [event("initialize")], [event("initialize"), event("tools/call")], + [event("initialize"), event("initialize"), event("tools/list")]] + for replacement in ({"facts": {}}, {"nonce": "foreign"}, {"pid": True}, {"session": "7-0100"}, {"timestamp": "2026-10-29T22:00:02Z"}, {"unknown": True}): + altered = copy.deepcopy(rows) + altered[1].update(replacement) + hostile.append(altered) + for captured in hostile: + with self.assertRaises(ValueError): + cycle.probe_delta(raw(captured), "held", start, end, set()) + with self.assertRaises(ValueError): + cycle.probe_delta(raw(rows), "held", start, end, {"8-100"}) + # RFC3339Nano must not lose the final three fractional digits. + left = cycle.stamp("2026-10-29T22:00:00.000000100Z") + right = cycle.stamp("2026-10-29T22:00:00.000000200Z") + reversed_rows = [event("initialize", timestamp="2026-10-29T22:00:00.000000150Z"), event("tools/list", timestamp="2026-10-29T22:00:00.000000149Z")] + with self.assertRaises(ValueError): + cycle.probe_delta(raw(reversed_rows), "held", left, right, set()) + outside_rows = [event("initialize", timestamp="2026-10-29T22:00:00.000000099Z"), event("tools/list", timestamp="2026-10-29T22:00:00.000000150Z")] + with self.assertRaises(ValueError): + cycle.probe_delta(raw(outside_rows), "held", left, right, set()) + + def test_proof_stage_binds_scope_and_updated_revision(self): + from project_native_cursor_cycle_proof import validate + for stage, replacement in (("add-a", "add-b"), ("update-a", "intact-repair-a"), ("missing-repair-a", "intact-repair-a")): + commands, proofs, _, result = synthetic_cycle() + original = next(row for row in proofs if row["stage"] == replacement) + next(row for row in proofs if row["stage"] == stage)["value"] = copy.deepcopy(original["value"]) + with self.assertRaises(ValueError): + validate(proofs, result, commands) + + def test_full_inert_public_state_approval_probe_join(self): + captured = synthetic_cycle() + with patch.object(cycle, "pins") as pins: + cycle.complete(*captured) + pins.assert_called_once() + + def test_full_serialized_archive_admission_includes_approval_containers(self): + commands, proofs, probes, result = synthetic_cycle() + source = {"source_commit": "a" * 40, "source_tree": "b" * 40} + summary = {"client": "cursor", "version": "2026.10.22-27dcf5f", "capture_mode": cycle.MODE, + "activation": "prepared", "runtime_attempt_complete": True, "test_failed": False, + "cycle_observed_complete": True, "vendor_authentication_qualified": False, + "real_model_qualified": False, "qualification": {key: key != "mcp-tools-call" for key in cycle.FLAGS}, + "qualification_scope": cycle.SCOPE, "acquisition": {}, "scanner": {}, + "source_identity": {"installer_base_commit": source["source_commit"], "installer_tree": source["source_tree"], "installer_source_state": "committed", "installer_patch_sha256": []}, + "cycle_command_stages": [row["stage"] for row in commands], + "cycle_proof_stages": [row["stage"] for row in proofs], **result} + bodies = {name: b"".join(compact(row) + b"\n" for row in rows) + for name, rows in zip(cycle.MEMBERS, (commands, proofs, probes))} + bodies["runtime-attempt.json"] = compact(summary) + with tempfile.TemporaryDirectory() as temporary, patch.object(cycle, "pins") as pins: + path = Path(temporary) / "research.zip" + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, body in bodies.items(): + archive.writestr(name, body) + admitted, observed = runtime.admit_runtime(path, "cursor", source) + self.assertEqual(admitted, bodies) + self.assertEqual(observed, summary) + pins.assert_called_once() + + def test_approval_containers_are_finite_and_inner_streams_remain_closed(self): + _, proofs, _, _ = synthetic_cycle() + for stage in ("approve-a-one", "approve-b-one", "approve-a-two"): + prefix = copy.deepcopy(proofs[:cycle.PROOFS.index(stage) + 1]) + raw = lambda rows: b"".join(compact(row) + b"\n" for row in rows) + self.assertEqual(cycle.records(raw(prefix), cycle.PROOFS), prefix) + for fault in ("extra", "missing", "body-string", "inner-extra", "inner-missing", "inner-bytes", "inner-base64"): + hostile = copy.deepcopy(prefix) + value = hostile[-1]["value"] + if fault == "extra": + value["unknown"] = True + elif fault == "missing": + del value["scope"] + elif fault == "body-string": + value["body"] = "unframed" + elif fault == "inner-extra": + value["body"]["unknown"] = True + elif fault == "inner-missing": + del value["body"]["sha256"] + elif fault == "inner-bytes": + value["body"]["bytes"] = True + else: + value["body"]["body"] += "\n" + with self.subTest(stage=stage, fault=fault), self.assertRaises(ValueError): + cycle.records(raw(hostile), cycle.PROOFS) + foreign = [{"stage": cycle.PROOFS[0], "value": copy.deepcopy(proofs[4]["value"])}] + with self.assertRaises(ValueError): + cycle.records(raw(foreign), cycle.PROOFS) + foreign = [{"stage": cycle.STAGES[0], "value": copy.deepcopy(proofs[4]["value"])}] + with self.assertRaises(ValueError): + cycle.records(raw(foreign), cycle.STAGES) + + def test_literal_stages_and_exact_mode_dispatch(self): + commands, _, _, _ = synthetic_cycle() + self.assertEqual(tuple(r["stage"] for r in commands), cycle.STAGES) + for client in ("qoder", "grok", "kiro", "windsurf"): + with self.assertRaises(ValueError): + runtime.cli_attempt_members({"runtime-attempt.json": compact({"capture_mode": cycle.MODE})}, {}, client) + + def test_public_stage_no_change_and_group_fields_are_exact(self): + for stage in ("add-a", "repeat-add-a", "unchanged-update-a", "intact-repair-a", "update-a", "missing-repair-a"): + for field in ("no_change", "group_phase"): + for fault in ("presence", "value"): + captured = synthetic_cycle() + command = next(row["value"] for row in captured[0] if row["stage"] == stage) + document = json.loads(cycle.stream(command["stdout"])) + output = document["data"] if stage == "repeat-add-a" else document["data"]["targets"][0]["output"] + outcome = output["result"] + if fault == "presence" and field in outcome: + del outcome[field] + else: + outcome[field] = False if fault == "value" else True + command["stdout"] = wire(compact(document)) + with self.subTest(stage=stage, field=field, fault=fault), patch.object(cycle, "pins"), self.assertRaises(ValueError): + cycle.complete(*captured) + + def test_stream_canonical_bounds_and_privacy(self): + self.assertEqual(cycle.stream({"bytes": 0, "sha256": sha(b""), "body": ""}), b"") + self.assertEqual(cycle.stream(wire(b"ordinary\n")), b"ordinary\n") + for value in ({**wire(b"x"), "bytes": True}, {**wire(b"x"), "sha256": "0" * 64}, {**wire(b"x"), "body": "eA==\n"}, wire(b"uap-cursor-private-canary"), wire(b"x" * ((1 << 20) + 1))): + with self.assertRaises(ValueError): + cycle.stream(value) + + def test_complete_refuses_each_independent_hostile(self): + def output(captured, stage): + return next(row["value"] for row in captured[0] if row["stage"] == stage) + def proof(captured, stage): + return next(row["value"] for row in captured[1] if row["stage"] == stage) + def bad_public(captured): + row = output(captured, "repeat-add-a") + value = json.loads(base64.b64decode(row["stdout"]["body"])) + value["data"]["result"]["mutated"] = True + row["stdout"] = wire(compact(value)) + def bad_state(captured): + row = proof(captured, "update-a") + value = json.loads(base64.b64decode(row["state"]["body"])) + i = value["installations"][0] + next(iter(i["clients"].values()))["activation"] = "active" + row["state"] = wire(compact(value)) + hostiles = [lambda x: output(x, "installed-a")["argv"].append("--approve-all"), + lambda x: output(x, "final-outside").update(started_at="2026-10-29T23:00:34Z", ended_at="2026-10-29T23:00:34.5Z"), + lambda x: x[3].update(elapsed_ms=17500), + lambda x: output(x, "remaining-b").update(cwd="/owned/project"), + lambda x: output(x, "repaired-a").update(exit=1), + lambda x: proof(x, "approve-a-two")["fingerprints"].reverse(), + lambda x: proof(x, "approve-b-one").update(path="/owned/home/.cursor/mcp-approvals.json"), + lambda x: proof(x, "source-a-1.1.0").update(probe_sha256="0" * 64), + lambda x: proof(x, "missing-native-a").update(path="/owned/project-B/.cursor/mcp.json"), + lambda x: proof(x, "remove-a")["data"].update(caller=wire(b"foreign")), + lambda x: x[2][8]["value"].update(delta=wire(b"")), + lambda x: x[2][16]["value"].update(before_sha256="0" * 64), + lambda x: x[3]["cycle_probe"].update(sessions=0), bad_public, bad_state] + for mutate in hostiles: + captured = synthetic_cycle() + mutate(captured) + with self.subTest(mutate=repr(mutate)), patch.object(cycle, "pins"), self.assertRaises(ValueError): + cycle.complete(*captured) + + def test_pin_boundary_refuses_unknown_inventory(self): + with self.assertRaises(ValueError): + cycle.pins({"acquisition": {"client": "cursor", "acquisition": {}, "runtime_files": []}}, {"client": "/pin/dist-package/cursor-agent"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_project_native_cursor_runtime.py b/scripts/test_project_native_cursor_runtime.py new file mode 100644 index 000000000..63eb7289d --- /dev/null +++ b/scripts/test_project_native_cursor_runtime.py @@ -0,0 +1,51 @@ +"""Inert first Cursor component admission; no vendor process is executed.""" +import json +import unittest + +import project_native_runtime as runtime + + +class CursorRuntimeTests(unittest.TestCase): + source = {"source_commit": "a" * 40, "source_tree": "b" * 40} + + def result(self): + return {"client": "cursor", "version": "2026.10.22-27dcf5f", "activation": "prepared", + "runtime_attempt_complete": False, "test_failed": True, + "vendor_authentication_qualified": False, "real_model_qualified": False, + "qualification": {key: False for key in runtime.shared.QUALIFICATION_CASES}, + "source_identity": {"installer_base_commit": "a" * 40, "installer_tree": "b" * 40, + "installer_source_state": "committed", "installer_patch_sha256": []}} + + def test_first_capture_registers_exact_cursor_test_and_only_cursor_labels(self): + self.assertEqual(runtime.CASES["cursor"], { + "test": "TestAgentpluginsCursorNativeProjectCompleteMCPCycle", + "file": "repotests/agentplugins_cursor_cycle_finish_test.go", + "optin": "AGENTPLUGINS_CURSOR_PROJECT_RUNTIME_ATTEMPT", "seconds": 180, "phase": "runtime-attempt"}) + labels = ("empty-a-mcp", "empty-b-mcp", "empty-outside-mcp", "control-b-mcp", "control-outside-mcp", + "approve-a-mcp", "native-tools-a") + row = {"argv": ["/pin/cursor-agent", "mcp", "list"], "cwd": "/owned/A", "exit": 0, + "error": False, "deadline": False, "elapsed_ms": 1, + "cleanup": "full descendant supervision; forced cleanup fails"} + for label in labels: + bodies = {"runtime-attempt.json": json.dumps(self.result()).encode(), + label + ".command.json": json.dumps(row).encode(), + label + ".stdout.log": b"opaque native capture\n", label + ".stderr.log": b""} + self.assertTrue(runtime.cli_attempt_members(bodies, self.source, "cursor")["test_failed"]) + for client in ("grok", "qoder", "kiro", "windsurf"): + other = {**self.result(), "client": client, "version": runtime.CLI_ATTEMPTS[client]} + with self.assertRaises(ValueError): + runtime.cli_attempt_members({**bodies, "runtime-attempt.json": json.dumps(other).encode()}, self.source, client) + with self.assertRaises(ValueError): + runtime.cli_attempt_members({k: v for k, v in bodies.items() if k != label + ".stderr.log"}, self.source, "cursor") + + def test_native_text_cannot_qualify_initial_capture(self): + for change in ({"test_failed": False}, {"runtime_attempt_complete": True}, + {"vendor_authentication_qualified": True}, {"real_model_qualified": True}, + {"version": "latest"}, {"source_identity": {}}, + {"qualification": {key: True for key in runtime.shared.QUALIFICATION_CASES}}): + with self.assertRaises(ValueError): + runtime.cli_attempt_members({"runtime-attempt.json": json.dumps({**self.result(), **change}).encode()}, self.source, "cursor") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_project_native_cursor_transport.py b/scripts/test_project_native_cursor_transport.py new file mode 100644 index 000000000..680df5b39 --- /dev/null +++ b/scripts/test_project_native_cursor_transport.py @@ -0,0 +1,118 @@ +"""Inert Cursor publisher transport admission; no network or vendor process.""" +import gzip +import hashlib +import io +import os +from pathlib import Path +import tarfile +import unittest +from unittest.mock import patch + +import project_native_cursor_acquisition as transport +import project_native_research as research + + +class CursorPublisherTransportTests(unittest.TestCase): + def setUp(self): + self.spec = {"url": transport.URL, "format": "tar.gz", "max_bytes": 4096, + "transport": transport.TRANSPORT} + + def probe(self, body=b"inert", *, version=b"curl 8.5.0 (inert)\n", mutate=None, + result=None, output=None): + def run(argv, cwd, env, directory, label, **limits): + self.assertEqual(env, {"PATH": "/usr/bin:/bin", "HOME": str(cwd), "LANG": "C", "LC_ALL": "C"}) + self.assertEqual(cwd, directory) + self.assertEqual(cwd.stat().st_mode & 0o777, 0o700) + self.assertEqual(limits["limit"], 16384) + self.assertIs(limits["memory_only"], True) + self.assertLessEqual(limits["timeout"], 90) + if label == "cursor-curl-version": + self.assertEqual(argv, ["/usr/bin/curl", "-q", "--version"]) + return {"error": None, "exit": 0}, version, b"" + self.assertEqual(label, "cursor-curl-download") + target = Path(argv[argv.index("--output") + 1]) + self.assertEqual(target.parent, cwd) + self.assertEqual(target.stat().st_mode & 0o777, 0o600) + self.assertEqual(target.read_bytes(), b"") + self.assertEqual(argv[:6], ["/usr/bin/curl", "-q", "-fSL", "--silent", "--proto", "=https"]) + self.assertEqual(argv[argv.index("--proto-redir") + 1], "=https") + self.assertEqual(argv[argv.index("--max-redirs") + 1], "0") + self.assertEqual(argv[argv.index("--max-filesize") + 1], str(self.spec["max_bytes"])) + self.assertEqual(argv[-1], transport.URL) + target.write_bytes(body) + if mutate is not None: + mutate(target) + return result or {"error": None, "exit": 0}, output or b"200\n" + transport.URL.encode() + b"\n", b"" + return run + + def test_supervised_exact_transport_has_closed_environment_and_removes_private_file(self): + held = [] + original = self.probe() + def record(*args, **kwargs): + held.append(args[1]) + return original(*args, **kwargs) + with patch.dict(os.environ, {"HTTPS_PROXY": "forbidden", "CURL_HOME": "forbidden", "CURSOR_API_KEY": "forbidden"}), patch.object(transport, "run_probe", side_effect=record): + self.assertEqual(transport.download(self.spec, research.Budget()), (b"inert", transport.URL)) + self.assertTrue(held) + self.assertTrue(all(not root.exists() for root in held)) + + def test_old_curl_failure_redirect_or_process_error_cannot_yield_archive(self): + variants = ({"version": b"curl 8.3.0 (inert)\n"}, {"version": b"unknown\n"}, + {"result": {"error": None, "exit": 22}, "output": b"403\n" + transport.URL.encode() + b"\n"}, + {"result": {"error": "deadline", "exit": -9}}, + {"output": b"200\nhttps://user:password@other.invalid/archive\n"}, + {"output": b"302\n" + transport.URL.encode() + b"\n"}) + for variant in variants: + with self.subTest(variant=variant), patch.object(transport, "run_probe", side_effect=self.probe(**variant)): + with self.assertRaises(ValueError): + transport.download(self.spec, research.Budget()) + + def test_replaced_linked_or_oversized_output_is_refused(self): + def replace(target): + other = target.with_name("replacement") + other.write_bytes(b"inert") + other.chmod(0o600) + other.replace(target) + def symlink(target): + target.unlink() + target.symlink_to("missing") + def hardlink(target): + os.link(target, target.with_name("alias")) + for mutate in (replace, symlink, hardlink, lambda target: target.write_bytes(b"x" * 4097)): + with self.subTest(mutate=mutate), patch.object(transport, "run_probe", side_effect=self.probe(mutate=mutate)): + with self.assertRaises(ValueError): + transport.download(self.spec, research.Budget()) + + def test_existing_size_digest_and_inventory_gates_still_apply(self): + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w:gz") as archive: + member = tarfile.TarInfo("dist-package/cursor-agent") + member.size = 5 + archive.addfile(member, io.BytesIO(b"inert")) + body = output.getvalue() + self.spec.update(integrity="sha256:" + hashlib.sha256(body).hexdigest(), expected_bytes=len(body)) + with patch.object(transport, "run_probe", side_effect=self.probe(body)): + receipt, contents = research.acquire(self.spec, research.Budget()) + self.assertIs(receipt["pin_verified"], True) + self.assertEqual(contents, {"dist-package/cursor-agent": b"inert"}) + for changed in ({"expected_bytes": len(body) + 1}, {"integrity": "sha256:" + "0" * 64}, {"max_expanded_bytes": 4}): + with self.subTest(changed=changed), patch.object(transport, "run_probe", side_effect=self.probe(body)): + with self.assertRaises(ValueError): + research.acquire({**self.spec, **changed}, research.Budget()) + + def test_unregistered_transport_is_refused_before_child_and_urllib_remains_default(self): + with patch.object(transport, "run_probe", side_effect=AssertionError("child")): + for changed in ({"url": "https://x.ai/other.tar.gz"}, {"transport": "unknown"}): + with self.subTest(changed=changed), self.assertRaises(ValueError): + research.acquire({**self.spec, **changed}, research.Budget()) + body = gzip.compress(b"inert") + response = io.BytesIO(body) + response.url = "https://x.ai/inert.gz" + with patch.object(transport, "download", side_effect=AssertionError("Cursor transport")), patch.object(research.urllib.request, "build_opener") as opener: + opener.return_value.open.return_value = response + _, contents = research.acquire({"url": response.url, "format": "gzip", "max_bytes": 4096}, research.Budget()) + self.assertEqual(contents, {"payload": b"inert"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_project_native_runtime.py b/scripts/test_project_native_runtime.py index 3f5f9ccb6..43cf1ed38 100644 --- a/scripts/test_project_native_runtime.py +++ b/scripts/test_project_native_runtime.py @@ -6,6 +6,7 @@ from pathlib import Path import stat import tempfile +import types import unittest from unittest.mock import patch import zipfile @@ -16,6 +17,49 @@ class RuntimeTests(unittest.TestCase): source = {"source_commit": "a" * 40, "source_tree": "b" * 40} + def test_cursor_refusal_reports_only_owned_callsite_on_stderr(self): + import project_native_cursor_cycle as cycle + try: + cycle.admit({}, self.source) + except ValueError as original: + try: + raise ValueError("uap-cursor-private-secret password=must-not-escape") from original + except ValueError as error: + with patch("sys.stderr", new_callable=io.StringIO) as stderr, patch("sys.stdout", new_callable=io.StringIO) as stdout: + runtime.cursor_admission_refusal(error) + self.assertEqual(stdout.getvalue(), "") + self.assertRegex(stderr.getvalue(), r"^Cursor native admission refused at project_native_cursor_cycle\.py:[1-9][0-9]{0,5}\n$") + self.assertNotIn("secret", stderr.getvalue()) + self.assertLessEqual(len(stderr.getvalue().encode()), 256) + + def test_cursor_refusal_ignores_external_same_basename_and_error_text(self): + code = compile('raise ValueError("uap-cursor-private-secret" * 1000)', "/foreign/project_native_cursor_cycle.py", "exec") + try: + exec(code) + except ValueError as error: + with patch("sys.stderr", new_callable=io.StringIO) as stderr, patch("sys.stdout", new_callable=io.StringIO) as stdout: + runtime.cursor_admission_refusal(error) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "Cursor native admission refused at unavailable\n") + + def test_cursor_refusal_bounds_trace_walk_and_source_line(self): + import project_native_cursor_cycle as cycle + try: + cycle.admit({}, self.source) + except ValueError as original: + trace = original.__traceback__ + while trace.tb_frame.f_code.co_filename != str(runtime.SOURCE / "scripts/project_native_cursor_cycle.py"): + trace = trace.tb_next + for count, line in ((129, 1), (1, 0), (1, 1000000)): + chain = None + for _ in range(count): + chain = types.TracebackType(chain, trace.tb_frame, trace.tb_lasti, line) + error = ValueError("uap-cursor-private-secret").with_traceback(chain) + with self.subTest(count=count, line=line), patch("sys.stderr", new_callable=io.StringIO) as stderr, patch("sys.stdout", new_callable=io.StringIO) as stdout: + runtime.cursor_admission_refusal(error) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "Cursor native admission refused at unavailable\n") + def result(self): return {"client": "copilot", "provider_kind": "scripted_loopback", "activation": "prepared", "runtime_attempt_complete": False, "vendor_authentication_qualified": False, @@ -1473,6 +1517,8 @@ def load_tests(loader, tests, pattern): tests.addTests(loader.loadTestsFromName("test_project_native_cline_cycle.CompleteCycleTests")) tests.addTests(loader.loadTestsFromName("test_project_native_grok_cycle.GrokCycleContracts")) tests.addTests(loader.loadTestsFromName("test_project_native_qoder_cycle.QoderCycleContracts")) + tests.addTests(loader.loadTestsFromName("test_project_native_cursor_runtime.CursorRuntimeTests")) + tests.addTests(loader.loadTestsFromName("test_project_native_cursor_cycle.CursorCycleContracts")) return tests