From f221ca1910f6c59a5a002c25b52e02e3ec472e37 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 09:48:54 -0500 Subject: [PATCH 1/5] Prove Copilot permission recovery after provider death without replay Co-Authored-By: Paperclip --- .../runner-copilot-capabilities.md | 11 +++-- .../scripts/probe-copilot-acp.py | 41 ++++++++++++++----- ...copilot-policy-conformance-2026-09-29.json | 26 +++++++++++- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index f2747a92154..ac06f60ce46 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -41,14 +41,19 @@ answer cannot be delivered. This narrows the blocking-input question for the admitted pinned mode without claiming an upstream question responder exists. The [offline conformance record](../../packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json) -retains seven attempts and their evidence digests. A protected file outside the +retains eight attempts and their evidence digests. A protected file outside the working directory was denied before its contents reached the fixture model. A durable conversation was closed and loaded, then requested permission for a shell write; `reject_once` prevented the file. The initial attempt to load an empty conversation failed with resource-not-found and remains retained. The second attempt seeds one text turn before close/load; it is not a retry of a -measured behavior failure. This proves a local native close/load permission -boundary, not process-death recovery or a final packaged Product case. +measured behavior failure. A separate +process-death attempt first performs one allowed seed append, then SIGKILLs and +replaces the exact provider using the same private session state. Load succeeds, +the seed append still occurs exactly once, and a fresh denied write remains +absent. Both native processes use permission request ID 0, proving why response +authority must belong to the current connection. These are local native +close/load and process-death probes, not final packaged Product qualification. All these probes use the verified 1.0.88 ARM64 binary, an explicitly constructed credential-free environment, `COPILOT_OFFLINE=true`, and a synthetic OpenAI-style diff --git a/packages/paperclip-runner/scripts/probe-copilot-acp.py b/packages/paperclip-runner/scripts/probe-copilot-acp.py index 83d5a545843..dc15bf7b351 100644 --- a/packages/paperclip-runner/scripts/probe-copilot-acp.py +++ b/packages/paperclip-runner/scripts/probe-copilot-acp.py @@ -7,7 +7,9 @@ parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--package-root',required=True) parser.add_argument('--scenario',choices=['deny-write','deny-shell','deny-read','attached-shell','discover-inputs','native-question','native-plan'],default='deny-write') parser.add_argument('--mode',choices=['agent','plan','autopilot'],default='agent') -parser.add_argument('--resume',action='store_true',help='Close and load the same native session before prompting') +resume_group=parser.add_mutually_exclusive_group() +resume_group.add_argument('--resume',action='store_true',help='Close and load the same native session before prompting') +resume_group.add_argument('--restart',action='store_true',help='SIGKILL and replace the provider after one allowed seed mutation; load and verify no replay') args=parser.parse_args() package_root=pathlib.Path(args.package_root).resolve() metadata=json.loads((package_root/'package.json').read_text()) @@ -39,6 +41,8 @@ with contextlib.ExitStack() as cleanup: calls=0 total_model_calls=0 session=None + seeding=False + seed_command='printf X >> replay-count.txt' final_prompt_id=None outside=root.parent/(root.name+'-protected.txt') outside.write_text('PRIVATE_FIXTURE_SENTINEL') @@ -57,11 +61,13 @@ with contextlib.ExitStack() as cleanup: data=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0')))) model_tool_names=sorted(set(model_tool_names+[x.get('function',{}).get('name') for x in data.get('tools',[]) if x.get('function',{}).get('name')])) model_tool_results.extend(x.get('content') for x in data.get('messages',[]) if x.get('role')=='tool') - if total_model_calls>5:raise RuntimeError('Fixture model exceeded its bounded request count') + if total_model_calls>6:raise RuntimeError('Fixture model exceeded its bounded request count') if data.get('stream'): self.send_response(200);self.send_header('Content-Type','text/event-stream');self.end_headers() values=[{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{'role':'assistant','content':'Fixture complete.'},'finish_reason':None}]},{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{},'finish_reason':'stop'}],'usage':{'prompt_tokens':10,'completion_tokens':3,'total_tokens':13}}] - if calls==1 and tool_name:values=[{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{'role':'assistant','tool_calls':[{'index':0,'id':'fixture-tool','type':'function','function':{'name':tool_name,'arguments':json.dumps(tool_arguments)}}]},'finish_reason':None}]},{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{},'finish_reason':'tool_calls'}]}] + selected_tool='bash' if seeding else tool_name + selected_arguments={'command':seed_command,'description':'Single non-replayed seed mutation'} if seeding else tool_arguments + if calls==1 and selected_tool:values=[{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{'role':'assistant','tool_calls':[{'index':0,'id':'fixture-tool','type':'function','function':{'name':selected_tool,'arguments':json.dumps(selected_arguments)}}]},'finish_reason':None}]},{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{},'finish_reason':'tool_calls'}]}] for value in values:self.wfile.write(('data: '+json.dumps(value)+'\n\n').encode()) self.wfile.write(b'data: [DONE]\n\n') else: @@ -73,7 +79,8 @@ with contextlib.ExitStack() as cleanup: env={'PATH':'/usr/bin:/bin','HOME':str(root/'home'),'XDG_CONFIG_HOME':str(root/'config'),'XDG_CACHE_HOME':str(root/'cache'),'XDG_DATA_HOME':str(root/'data'),'COPILOT_HOME':str(root/'copilot'),'COPILOT_CACHE_HOME':str(root/'copilot-cache'),'COPILOT_ALLOW_ALL':'false','COPILOT_PKG_CACHE_HOME':str(root/'extract'),'COPILOT_AUTO_UPDATE':'false','COPILOT_OFFLINE':'true','COPILOT_PROVIDER_BASE_URL':f'http://127.0.0.1:{server.server_port}','COPILOT_PROVIDER_TYPE':'openai','COPILOT_PROVIDER_MODEL_ID':'gpt-4.1','COPILOT_MODEL':'gpt-4.1','NO_COLOR':'1'} for key in ('HOME','XDG_CONFIG_HOME','XDG_CACHE_HOME','XDG_DATA_HOME','COPILOT_HOME','COPILOT_CACHE_HOME','COPILOT_PKG_CACHE_HOME'):pathlib.Path(env[key]).mkdir() (root/'copilot/config.json').write_text(json.dumps({'trustedFolders':[],'disableAllHooks':True,'memory':False,'ide':{'autoConnect':False}})) - p=subprocess.Popen([binary,'--acp','--stdio','--no-auto-update','--disable-builtin-mcps','--no-remote','--no-remote-export','--no-bash-env'],env=env,cwd=root,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + launch=[binary,'--acp','--stdio','--no-auto-update','--disable-builtin-mcps','--no-remote','--no-remote-export','--no-bash-env'] + p=subprocess.Popen(launch,env=env,cwd=root,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) cleanup.callback(stop_process, p) responses={};wire=[];permission_responses=[];buf={p.stdout:b'',p.stderr:b''};nextid=0;marker_at_prompt_result=False @@ -89,7 +96,8 @@ with contextlib.ExitStack() as cleanup: message=json.loads(line);wire.append(message) if 'id' in message and 'method' not in message:responses[message['id']]=message if message.get('method')=='session/request_permission': - allow=args.scenario=='attached-shell' and message['params'].get('toolCall',{}).get('rawInput',{}).get('command')==command + requested_command=message['params'].get('toolCall',{}).get('rawInput',{}).get('command') + allow=(args.scenario=='attached-shell' and requested_command==command) or (seeding and requested_command==seed_command) reject=next((o for o in message['params']['options'] if o['kind']==('allow_once' if allow else 'reject_once')),None) outcome={'outcome':'selected','optionId':reject['optionId']} if reject else {'outcome':'cancelled'} permission_responses.append({'id':message['id'],'outcome':outcome}) @@ -102,18 +110,28 @@ with contextlib.ExitStack() as cleanup: if n not in responses:raise TimeoutError(method) return responses[n] try: - request('initialize',{'protocolVersion':1,'clientCapabilities':{'_meta':{'github.com/copilot':{'events':['session.idle','session.plan_changed','session.background_tasks_changed','session.completion_receipt','user_input.requested','exit_plan_mode.requested','assistant.usage']}}},'clientInfo':{'name':'paperclip-offline-fixture','version':'1'}}) + initialize_params={'protocolVersion':1,'clientCapabilities':{'_meta':{'github.com/copilot':{'events':['session.idle','session.plan_changed','session.background_tasks_changed','session.completion_receipt','user_input.requested','exit_plan_mode.requested','assistant.usage']}}},'clientInfo':{'name':'paperclip-offline-fixture','version':'1'}} + request('initialize',initialize_params) session=request('session/new',{'cwd':str(root),'mcpServers':[]}) if 'result' in session: session_id=session['result']['sessionId'] if args.mode!='agent': request('session/set_mode',{'sessionId':session_id,'modeId':'https://agentclientprotocol.com/protocol/session-modes#'+args.mode}) - if args.resume: + if args.resume or args.restart: # A native session with no conversation is not durably resumable. - calls=-1 + seeding=args.restart + calls=0 if seeding else -1 request('session/prompt',{'sessionId':session_id,'prompt':[{'type':'text','text':'Initialize the fixture conversation.'}]}) + seeding=False calls=0 - request('session/close',{'sessionId':session_id}) + if args.restart: + assert (root/'replay-count.txt').read_text()=='X', 'Seed mutation did not execute exactly once' + p.kill();p.wait(timeout=5);stop_process(p) + p=subprocess.Popen(launch,env=env,cwd=root,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + cleanup.callback(stop_process,p) + buf={p.stdout:b'',p.stderr:b''} + request('initialize',initialize_params) + else:request('session/close',{'sessionId':session_id}) session=request('session/load',{'sessionId':session_id,'cwd':str(root),'mcpServers':[]}) if 'error' in session:raise RuntimeError('Copilot rejected session load') final_prompt_id=nextid+1 @@ -124,7 +142,7 @@ with contextlib.ExitStack() as cleanup: failure=sys.exc_info()[1] try: marker_exists=(root/('settlement.txt' if args.scenario=='attached-shell' else 'denied.txt')).exists() - report={'schema':'paperclip.copilot-acp-evidence/v1','harnessVersion':'1.0.88','package':metadata['name'],'executableSha256':pins[metadata['name']],'modelSource':'deterministic loopback fixture, COPILOT_OFFLINE=true; not a live model qualification','scenario':args.scenario,'mode':args.mode,'resumed':args.resume,'costUsd':0,'filesystemMarkerExistedAtPromptResult':marker_at_prompt_result,'filesystemMarkerExistedAfterPrompt':marker_exists,'modelCalls':total_model_calls,'modelToolNames':model_tool_names,'modelToolResults':model_tool_results,'permissionResponses':permission_responses,'wire':wire} + report={'schema':'paperclip.copilot-acp-evidence/v1','harnessVersion':'1.0.88','package':metadata['name'],'executableSha256':pins[metadata['name']],'modelSource':'deterministic loopback fixture, COPILOT_OFFLINE=true; not a live model qualification','scenario':args.scenario,'mode':args.mode,'resumed':args.resume or args.restart,'providerKilledAndReplaced':args.restart,'replayMutationCount':len((root/'replay-count.txt').read_text()) if (root/'replay-count.txt').exists() else None,'costUsd':0,'filesystemMarkerExistedAtPromptResult':marker_at_prompt_result,'filesystemMarkerExistedAfterPrompt':marker_exists,'modelCalls':total_model_calls,'modelToolNames':model_tool_names,'modelToolResults':model_tool_results,'permissionResponses':permission_responses,'wire':wire} serialized=json.dumps(report,indent=2).replace(str(root),'/fixture/workspace').replace(str(root).lstrip('/'),'fixture/workspace').replace(binary,'/fixture/verified/copilot') print(serialized) except Exception: @@ -144,3 +162,6 @@ with contextlib.ExitStack() as cleanup: if args.scenario in ('native-question','native-plan') and not any("Tool '"+str(tool_name)+"' does not exist" in str(result) for result in model_tool_results): raise RuntimeError('Forced native input did not prove explicit tool unavailability') + + if args.restart and (root/'replay-count.txt').read_text()!='X': + raise RuntimeError('Provider restart replayed or lost a seed mutation') diff --git a/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json b/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json index 4d99b27437f..74c16eb1474 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json +++ b/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json @@ -120,7 +120,31 @@ "markerPresent": false, "rpcErrorCodes": [], "disposition": "passed" + }, + { + "evidenceFile": "restart-deny-shell.json", + "sha256": "c9a22087f343d8d399044bd05d159e457531d3c735a765df2c6bdce85878b2be", + "scenario": "deny-shell", + "mode": "agent", + "resumed": true, + "providerKilledAndReplaced": true, + "replayMutationCount": 1, + "loopbackModelRequests": 3, + "nativeInputToolsAdvertised": [], + "permissionRequestCount": 2, + "permissionDecisions": [ + "allow_once", + "reject_once" + ], + "permissionRequestIds": [ + 0, + 0 + ], + "markerPresent": false, + "rpcErrorCodes": [], + "disposition": "passed" } ], - "emptySessionLoadDiagnosis": "Copilot does not retain an empty conversation for load. Initial resource-not-found retained; second attempt seeds one loopback-only text turn before close/load. It then requests shell permission and rejects the write." + "emptySessionLoadDiagnosis": "Copilot does not retain an empty conversation for load. Initial resource-not-found retained; second attempt seeds one loopback-only text turn before close/load. It then requests shell permission and rejects the write.", + "providerDeathDiagnosis": "One allowed seed mutation appends X. The exact provider is SIGKILLed and replaced against the same private state, then session/load succeeds. X occurs once after load and the next denied write remains absent. Request ID 0 is reused by the new process and requires fresh permission authority." } From fcb142168559e1be499afeeb5ee023f2867d55ab Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 09:51:04 -0500 Subject: [PATCH 2/5] Retain Copilot detached-command early completion regression Co-Authored-By: Paperclip --- .../runner-copilot-capabilities.md | 21 ++++++++++++-- .../scripts/probe-copilot-acp.py | 21 +++++++++----- ...copilot-policy-conformance-2026-09-29.json | 29 ++++++++++++++++++- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index ac06f60ce46..54162551324 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -41,7 +41,7 @@ answer cannot be delivered. This narrows the blocking-input question for the admitted pinned mode without claiming an upstream question responder exists. The [offline conformance record](../../packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json) -retains eight attempts and their evidence digests. A protected file outside the +retains ten attempts and their evidence digests. A protected file outside the working directory was denied before its contents reached the fixture model. A durable conversation was closed and loaded, then requested permission for a shell write; `reject_once` prevented the file. The initial attempt to load an @@ -69,6 +69,23 @@ answered Cobalt choice, and warm-session continuation. Copilot instead supplied `[terminal marker]` to `paperclip_finish`. Neither the grader nor the terminal marker requirement changed. A final-runtime rerun remains required. +### Detached background work remains a production blocker + +The broader 2026-09-29 deterministic probe reproduces early completion for +`bash` with `mode: async` and `detach: true`. After an allowed finite two-second +command, Copilot sends a completed tool update naming a detached shell ID, then +`session.idle` and `end_turn` before the marker file exists. A separate diagnostic +keeps observing for three seconds after terminal and proves the marker appears +late. Both attempts retain their failed settlement assertion. + +The preceding standard tool-call notification includes `detach: true`, but the +permission request carries only the command. Native background-task change +notices have empty payloads and expose no task-list or settle control. Neither +notification counts nor a fixed delay establish settlement. The prior live +finite-detached pass exercised a model that waited; it does not qualify this +native lifetime. A governed runner-owned background interface or a versioned +upstream settlement fix is required to support detached work safely. + ## Evidence and scope The audit inspected the exact `@github/copilot@1.0.88` platform archives, the @@ -211,7 +228,7 @@ Daytona claims require the separate live product qualification. | Task file changes / diffs | Standard tools carry locations/diff content for create/edit/str_replace/apply_patch. | Shared ACP tool activity retains bounded `rawOutput`, `inputUpdated` and the first validated relative location. Structured tool `content` diffs/images, `rawInput` and secondary locations are dropped; no complete diff presentation is claimed. | Real denied-create wire contains a diff, but wire presence is not runner/UI preservation. P1 add typed, bounded diff/image content and all validated locations with tool/session provenance; Product file-edit/validation and downloadable-file presentation pass; rich diff rendering remains unqualified. | | Provider workspace files | `session.workspace_file_changed.path` is relative to provider session workspace files, not task cwd. | Validated reference tagged `provider_session_workspace`, resolution required. | Source + traversal tests; P1 safe file retrieval/upload. | | Images / binary artifacts | Prompt image input; native content-addressed binary_asset base64. | Hash/length-validated metadata references; bytes not blindly read from disk or emitted in activity. | Source + unit digest tests; P1 durable artifact storage; >32 KiB payload provider omission remains. | -| Background settlement | Standard prompt waits for idle in tested attached async-shell case; lossy native idle/receipt also exist. | ACP terminal result remains authoritative; raw event cannot end turn. | Offline attached and real-service finite detached commands completed before end_turn; marker verified through cleanup. Arbitrary background lifetimes remain unqualified; P0 broader settlement coverage. | +| Background settlement | Standard prompt waits for idle in tested attached async-shell case; lossy native idle/receipt also exist. | ACP terminal result remains authoritative; raw event cannot end turn. | Offline attached and real-service finite detached commands completed before end_turn; marker verified through cleanup. A deterministic detach:true case now reproduces end_turn before the finite command completes; see the 2026-09-29 blocker. P0 actual background settlement is required. | | Compaction/context | Native compaction lifecycle/token counts/context git metadata. | Safe bounded counters/status, immutable workspace binding. | Source + projection tests; raw summary/private custom instructions omitted. | | Goals / remote/schedules | Native autopilot/objectives/remote/schedule facilities; no qualified ACP goal protocol. | Unsupported through this profile; remote disabled. | Source; P2 separate governance review before control exposure. | diff --git a/packages/paperclip-runner/scripts/probe-copilot-acp.py b/packages/paperclip-runner/scripts/probe-copilot-acp.py index dc15bf7b351..7a5546e17f8 100644 --- a/packages/paperclip-runner/scripts/probe-copilot-acp.py +++ b/packages/paperclip-runner/scripts/probe-copilot-acp.py @@ -5,7 +5,7 @@ This proves a protocol fixture, never GitHub/model/Daytona qualification. import argparse,json,subprocess,tempfile,pathlib,os,select,time,threading,http.server,hashlib,shutil,contextlib,sys parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--package-root',required=True) -parser.add_argument('--scenario',choices=['deny-write','deny-shell','deny-read','attached-shell','discover-inputs','native-question','native-plan'],default='deny-write') +parser.add_argument('--scenario',choices=['deny-write','deny-shell','deny-read','attached-shell','detached-shell','discover-inputs','native-question','native-plan'],default='deny-write') parser.add_argument('--mode',choices=['agent','plan','autopilot'],default='agent') resume_group=parser.add_mutually_exclusive_group() resume_group.add_argument('--resume',action='store_true',help='Close and load the same native session before prompting') @@ -47,8 +47,8 @@ with contextlib.ExitStack() as cleanup: outside=root.parent/(root.name+'-protected.txt') outside.write_text('PRIVATE_FIXTURE_SENTINEL') cleanup.callback(outside.unlink,missing_ok=True) - tool_name={'deny-write':'create','deny-shell':'bash','deny-read':'view','attached-shell':'bash','native-question':'ask_user','native-plan':'exit_plan_mode'}.get(args.scenario) - tool_arguments={'deny-write':{'path':str(root/'denied.txt'),'file_text':'MUST NOT EXIST'},'deny-shell':{'command':'printf MUST_NOT_EXIST > denied.txt','description':'Denied command fixture'},'deny-read':{'path':str(outside)},'attached-shell':{'command':command,'description':'Bounded settlement fixture','mode':'async','detach':False},'native-question':{'question':'Choose one','choices':['A','B'],'allowFreeform':False},'native-plan':{'summary':'Fixture plan','planContent':'Do fixture work'}}.get(args.scenario) + tool_name={'deny-write':'create','deny-shell':'bash','deny-read':'view','attached-shell':'bash','detached-shell':'bash','native-question':'ask_user','native-plan':'exit_plan_mode'}.get(args.scenario) + tool_arguments={'deny-write':{'path':str(root/'denied.txt'),'file_text':'MUST NOT EXIST'},'deny-shell':{'command':'printf MUST_NOT_EXIST > denied.txt','description':'Denied command fixture'},'deny-read':{'path':str(outside)},'attached-shell':{'command':command,'description':'Bounded settlement fixture','mode':'async','detach':False},'detached-shell':{'command':command,'description':'Bounded detached settlement fixture','mode':'async','detach':True},'native-question':{'question':'Choose one','choices':['A','B'],'allowFreeform':False},'native-plan':{'summary':'Fixture plan','planContent':'Do fixture work'}}.get(args.scenario) marker_at_prompt_result=False class Handler(http.server.BaseHTTPRequestHandler): def log_message(self,*args):pass @@ -97,7 +97,7 @@ with contextlib.ExitStack() as cleanup: if 'id' in message and 'method' not in message:responses[message['id']]=message if message.get('method')=='session/request_permission': requested_command=message['params'].get('toolCall',{}).get('rawInput',{}).get('command') - allow=(args.scenario=='attached-shell' and requested_command==command) or (seeding and requested_command==seed_command) + allow=(args.scenario in ('attached-shell','detached-shell') and requested_command==command) or (seeding and requested_command==seed_command) reject=next((o for o in message['params']['options'] if o['kind']==('allow_once' if allow else 'reject_once')),None) outcome={'outcome':'selected','optionId':reject['optionId']} if reject else {'outcome':'cancelled'} permission_responses.append({'id':message['id'],'outcome':outcome}) @@ -136,12 +136,17 @@ with contextlib.ExitStack() as cleanup: if 'error' in session:raise RuntimeError('Copilot rejected session load') final_prompt_id=nextid+1 request('session/prompt',{'sessionId':session_id,'prompt':[{'type':'text','text':'Reply with Fixture complete.'}]}) - marker_at_prompt_result=(root/('settlement.txt' if args.scenario=='attached-shell' else 'denied.txt')).exists() - for _ in range(3):pump(.1) + marker_at_prompt_result=(root/('settlement.txt' if args.scenario in ('attached-shell','detached-shell') else 'denied.txt')).exists() + if args.scenario=='detached-shell': + # Observe the bounded child after terminal; never turn a late marker into success. + observation_deadline=time.monotonic()+3 + while time.monotonic() Date: Tue, 29 Sep 2026 09:53:20 -0500 Subject: [PATCH 3/5] Bind Daytona image identity to Cursor isolation patch Co-Authored-By: Paperclip --- tests/runner-e2e/daytona-image-content.ts | 1 + tests/runner-e2e/daytona-image.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts index 13cd2b58f5b..4ab5b2e4a77 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -38,6 +38,7 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [ "packages/paperclip-runner/scripts/build-provider-pack.mjs", "packages/paperclip-runner/scripts/candidate-provider-pack.mjs", "packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs", + "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs", "packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs", "packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs", "packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs", diff --git a/tests/runner-e2e/daytona-image.test.ts b/tests/runner-e2e/daytona-image.test.ts index 3087aab83ec..95896295424 100644 --- a/tests/runner-e2e/daytona-image.test.ts +++ b/tests/runner-e2e/daytona-image.test.ts @@ -201,6 +201,7 @@ describe("runner E2E Daytona image contract", () => { "packages/paperclip-runner/package.json", "packages/paperclip-runner/scripts/candidate-provider-pack.mjs", "packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs", + "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs", "packages/paperclip-runner/cursor-distributions.json", "packages/paperclip-runner/runner/crates", "packages/paperclip-runner/src", @@ -331,6 +332,28 @@ describe("runner E2E Daytona image contract", () => { } }); + it("invalidates the image when the Cursor runtime isolation patch changes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paperclip-cursor-image-id-")); + const patchPath = "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs"; + expect(DAYTONA_IMAGE_INPUT_PATHS).toContain(patchPath); + const options = { + repositoryRoot: root, + inputPaths: [patchPath], + baseImages: [`example.test/base:1@sha256:${"a".repeat(64)}`], + frontendDigest: `sha256:${"c".repeat(64)}`, + candidateProviders: ["cursor"], + } as const; + try { + await mkdir(path.dirname(path.join(root, patchPath)), { recursive: true }); + await writeFile(path.join(root, patchPath), "export const policy = 'one';\n"); + const baseline = await computeDaytonaImageContentId(options); + await writeFile(path.join(root, patchPath), "export const policy = 'two';\n"); + expect(await computeDaytonaImageContentId(options)).not.toBe(baseline); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("reuses the image for runner-only tests and documentation", async () => { const root = await mkdtemp( path.join(tmpdir(), "paperclip-daytona-runner-development-inputs-"), From f9cd8dd25692b1510093fb50b40924fc29ab4f50 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 09:58:05 -0500 Subject: [PATCH 4/5] Deny native Copilot detached work before permission dispatch Co-Authored-By: Paperclip --- .../runner-copilot-capabilities.md | 48 ++++++++--- .../scripts/probe-copilot-acp.py | 31 ++++++- .../scripts/probe-copilot-tool-policy.mjs | 10 +++ .../src/drivers/acpx/copilot-policy.test.ts | 14 ++++ .../src/drivers/acpx/copilot-policy.ts | 28 +++++-- .../acpx/copilot-protocol-guard.test.ts | 15 ++++ ...copilot-policy-conformance-2026-09-29.json | 82 ++++++++++++++++++- 7 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 packages/paperclip-runner/scripts/probe-copilot-tool-policy.mjs diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index 54162551324..2ff06fdf21e 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -41,7 +41,7 @@ answer cannot be delivered. This narrows the blocking-input question for the admitted pinned mode without claiming an upstream question responder exists. The [offline conformance record](../../packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json) -retains ten attempts and their evidence digests. A protected file outside the +retains every attempt and its evidence digest, including the isolated comparison release. A protected file outside the working directory was denied before its contents reached the fixture model. A durable conversation was closed and loaded, then requested permission for a shell write; `reject_once` prevented the file. The initial attempt to load an @@ -55,7 +55,8 @@ absent. Both native processes use permission request ID 0, proving why response authority must belong to the current connection. These are local native close/load and process-death probes, not final packaged Product qualification. -All these probes use the verified 1.0.88 ARM64 binary, an explicitly constructed +Production-pin probes use the verified 1.0.88 ARM64 binary; an explicitly selected +1.0.89 comparison has separately verified archive integrity. All use an explicitly constructed credential-free environment, `COPILOT_OFFLINE=true`, and a synthetic OpenAI-style model at an ephemeral loopback HTTP server. The synthetic `gpt-4.1` ID does not name an authenticated GitHub model selection. Counts include the setup turn. @@ -69,7 +70,7 @@ answered Cobalt choice, and warm-session continuation. Copilot instead supplied `[terminal marker]` to `paperclip_finish`. Neither the grader nor the terminal marker requirement changed. A final-runtime rerun remains required. -### Detached background work remains a production blocker +### Native detached work is explicitly unsupported The broader 2026-09-29 deterministic probe reproduces early completion for `bash` with `mode: async` and `detach: true`. After an allowed finite two-second @@ -78,13 +79,38 @@ command, Copilot sends a completed tool update naming a detached shell ID, then keeps observing for three seconds after terminal and proves the marker appears late. Both attempts retain their failed settlement assertion. -The preceding standard tool-call notification includes `detach: true`, but the -permission request carries only the command. Native background-task change -notices have empty payloads and expose no task-list or settle control. Neither -notification counts nor a fixed delay establish settlement. The prior live -finite-detached pass exercised a model that waited; it does not qualify this -native lifetime. A governed runner-owned background interface or a versioned -upstream settlement fix is required to support detached work safely. +The preceding standard tool-call notification includes `rawInput.detach: true`, +but the observed permission request carries only the command. The Copilot guard +rejects that update before the SDK can deliver any permission answer, terminates +the provider, and reports `COPILOT_DETACHED_WORK_UNSUPPORTED` with instructions to +run attached or use a managed runtime service. It also rejects detach carried +only in a permission request. Attached asynchronous work retains the normal +permission path. This is policy denial, not completed background settlement. + +A native offline probe invokes the exact production TypeScript tool-update +policy, kills the provider before permission dispatch, and observes no marker +through the command's bounded delay. It makes one loopback fixture request and +sends zero permission responses. Separate actual patched-ACPX stream tests prove +the same rejection before SDK delivery. The native probe intentionally tests +only tool admission: its synthetic model does not satisfy full production model +admission. Neither layer's result is misrepresented as final-pack live evidence. + +The bounded schema audit finds `detach` on `bash`, absent on `read_bash`, +`stop_bash`, and `task`; `write_bash` and PowerShell variants are not advertised +in this ARM64 catalog. Embedded JavaScript references `write_bash` but delegates +schemas to native Rust. The guard is independent of tool name, but this audit +cannot prove every platform/feature-flag variant or shell-created daemon +lifetime. Comprehensive background qualification remains blocked. Empty native +background-task notices and fixed delays cannot establish settlement. + +An isolated exact-integrity 1.0.89 comparison passes the same attached async +scenario and reproduces the detached late marker. Production remains pinned to +1.0.88. As checked on 2026-09-29, upstream #4743 is closed and explicitly concerns +attached async commands, distinguishing deliberate detached services. These +results do not establish that issue remains unfixed. #4537 remains open; the +narrow denied-write/read/recovery probes did not reproduce its permission bypass. +The original detached failures remain recorded as failures of the attempted +runner settlement contract; they are not relabeled as successful settlement. ## Evidence and scope @@ -228,7 +254,7 @@ Daytona claims require the separate live product qualification. | Task file changes / diffs | Standard tools carry locations/diff content for create/edit/str_replace/apply_patch. | Shared ACP tool activity retains bounded `rawOutput`, `inputUpdated` and the first validated relative location. Structured tool `content` diffs/images, `rawInput` and secondary locations are dropped; no complete diff presentation is claimed. | Real denied-create wire contains a diff, but wire presence is not runner/UI preservation. P1 add typed, bounded diff/image content and all validated locations with tool/session provenance; Product file-edit/validation and downloadable-file presentation pass; rich diff rendering remains unqualified. | | Provider workspace files | `session.workspace_file_changed.path` is relative to provider session workspace files, not task cwd. | Validated reference tagged `provider_session_workspace`, resolution required. | Source + traversal tests; P1 safe file retrieval/upload. | | Images / binary artifacts | Prompt image input; native content-addressed binary_asset base64. | Hash/length-validated metadata references; bytes not blindly read from disk or emitted in activity. | Source + unit digest tests; P1 durable artifact storage; >32 KiB payload provider omission remains. | -| Background settlement | Standard prompt waits for idle in tested attached async-shell case; lossy native idle/receipt also exist. | ACP terminal result remains authoritative; raw event cannot end turn. | Offline attached and real-service finite detached commands completed before end_turn; marker verified through cleanup. A deterministic detach:true case now reproduces end_turn before the finite command completes; see the 2026-09-29 blocker. P0 actual background settlement is required. | +| Background settlement | Standard prompt waits for idle in tested attached async-shell case; lossy native idle/receipt also exist. | ACP terminal result remains authoritative; raw event cannot end turn. | Offline attached and real-service finite detached commands completed before end_turn; marker verified through cleanup. A deterministic detach:true case now reproduces end_turn before the finite command completes; see the 2026-09-29 blocker. Native detach is now rejected before permission delivery; comprehensive background qualification remains blocked pending unproven tool/platform variants. | | Compaction/context | Native compaction lifecycle/token counts/context git metadata. | Safe bounded counters/status, immutable workspace binding. | Source + projection tests; raw summary/private custom instructions omitted. | | Goals / remote/schedules | Native autopilot/objectives/remote/schedule facilities; no qualified ACP goal protocol. | Unsupported through this profile; remote disabled. | Source; P2 separate governance review before control exposure. | diff --git a/packages/paperclip-runner/scripts/probe-copilot-acp.py b/packages/paperclip-runner/scripts/probe-copilot-acp.py index 7a5546e17f8..3f35957de58 100644 --- a/packages/paperclip-runner/scripts/probe-copilot-acp.py +++ b/packages/paperclip-runner/scripts/probe-copilot-acp.py @@ -5,20 +5,25 @@ This proves a protocol fixture, never GitHub/model/Daytona qualification. import argparse,json,subprocess,tempfile,pathlib,os,select,time,threading,http.server,hashlib,shutil,contextlib,sys parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--package-root',required=True) +parser.add_argument('--comparison-version',choices=['1.0.89'],help='Explicit offline comparison only; production remains pinned to 1.0.88') parser.add_argument('--scenario',choices=['deny-write','deny-shell','deny-read','attached-shell','detached-shell','discover-inputs','native-question','native-plan'],default='deny-write') parser.add_argument('--mode',choices=['agent','plan','autopilot'],default='agent') resume_group=parser.add_mutually_exclusive_group() resume_group.add_argument('--resume',action='store_true',help='Close and load the same native session before prompting') resume_group.add_argument('--restart',action='store_true',help='SIGKILL and replace the provider after one allowed seed mutation; load and verify no replay') +parser.add_argument('--tool-policy-node',help='Apply the repository tool policy before native permission dispatch using this Node 24 binary') args=parser.parse_args() +if args.tool_policy_node and args.scenario!='detached-shell':parser.error('--tool-policy-node requires detached-shell') package_root=pathlib.Path(args.package_root).resolve() metadata=json.loads((package_root/'package.json').read_text()) pins={'@github/copilot-darwin-arm64':'a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3','@github/copilot-darwin-x64':'85eb919f6b9b9dd833ce5e326cbf974b3ee2d4a9ac525c59d4ec9c9ec085715b','@github/copilot-linux-x64':'0059754cf78c3f3bf2c9d4564dfa7e9e25f3a3f8f411f2f0cdad9363f5662748'} -assert metadata['version']=='1.0.88' and metadata['name'] in pins +if args.comparison_version:pins={'@github/copilot-darwin-arm64':'97c12874d9adb9738374a9fb8a52cd724b81132ff815140f7e017bac706feba7'} +assert metadata['version']==(args.comparison_version or '1.0.88') and metadata['name'] in pins assert not (package_root/'copilot').is_symlink() assert hashlib.sha256((package_root/'copilot').read_bytes()).hexdigest()==pins[metadata['name']] model_tool_names=[] model_tool_results=[] +model_tool_schemas={} command='sleep 2; printf ACP_SHELL_DONE > settlement.txt' binary=str(package_root/'copilot') @@ -50,16 +55,21 @@ with contextlib.ExitStack() as cleanup: tool_name={'deny-write':'create','deny-shell':'bash','deny-read':'view','attached-shell':'bash','detached-shell':'bash','native-question':'ask_user','native-plan':'exit_plan_mode'}.get(args.scenario) tool_arguments={'deny-write':{'path':str(root/'denied.txt'),'file_text':'MUST NOT EXIST'},'deny-shell':{'command':'printf MUST_NOT_EXIST > denied.txt','description':'Denied command fixture'},'deny-read':{'path':str(outside)},'attached-shell':{'command':command,'description':'Bounded settlement fixture','mode':'async','detach':False},'detached-shell':{'command':command,'description':'Bounded detached settlement fixture','mode':'async','detach':True},'native-question':{'question':'Choose one','choices':['A','B'],'allowFreeform':False},'native-plan':{'summary':'Fixture plan','planContent':'Do fixture work'}}.get(args.scenario) marker_at_prompt_result=False + policy_rejection=None + class PolicyRejected(Exception):pass class Handler(http.server.BaseHTTPRequestHandler): def log_message(self,*args):pass def do_GET(self): self.send_response(200);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(json.dumps({'data':[{'id':'gpt-4.1','object':'model','owned_by':'fixture'}]}).encode()) def do_POST(self): - global calls,total_model_calls,model_tool_names,model_tool_results + global calls,total_model_calls,model_tool_names,model_tool_results,model_tool_schemas calls+=1 total_model_calls+=1 data=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0')))) model_tool_names=sorted(set(model_tool_names+[x.get('function',{}).get('name') for x in data.get('tools',[]) if x.get('function',{}).get('name')])) + for tool in data.get('tools',[]): + function=tool.get('function',{}) + if function.get('name') in ('bash','read_bash','write_bash','stop_bash','task','powershell','write_powershell','read_powershell'):model_tool_schemas[function['name']]=function model_tool_results.extend(x.get('content') for x in data.get('messages',[]) if x.get('role')=='tool') if total_model_calls>6:raise RuntimeError('Fixture model exceeded its bounded request count') if data.get('stream'): @@ -85,6 +95,7 @@ with contextlib.ExitStack() as cleanup: responses={};wire=[];permission_responses=[];buf={p.stdout:b'',p.stderr:b''};nextid=0;marker_at_prompt_result=False def pump(wait=1): + global policy_rejection for stream in select.select([p.stdout,p.stderr],[],[],wait)[0]: data=os.read(stream.fileno(),65536) if not data:continue @@ -94,6 +105,13 @@ with contextlib.ExitStack() as cleanup: if not line:continue if stream==p.stderr:continue message=json.loads(line);wire.append(message) + if args.tool_policy_node and message.get('method')=='session/update': + checked=subprocess.run([args.tool_policy_node,str(pathlib.Path(__file__).with_name('probe-copilot-tool-policy.mjs'))],input=json.dumps(message['params']['update']),text=True,capture_output=True,timeout=5,env={'PATH':'/usr/bin:/bin'}) + if checked.returncode: + if checked.returncode!=42 or checked.stdout.strip()!='COPILOT_DETACHED_WORK_UNSUPPORTED':raise RuntimeError('Repository tool policy probe failed unexpectedly') + policy_rejection=checked.stdout.strip() + p.terminate();p.wait(timeout=5) + raise PolicyRejected(policy_rejection) if 'id' in message and 'method' not in message:responses[message['id']]=message if message.get('method')=='session/request_permission': requested_command=message['params'].get('toolCall',{}).get('rawInput',{}).get('command') @@ -143,16 +161,23 @@ with contextlib.ExitStack() as cleanup: while time.monotonic() { it.each(["/yolo on", " /allow-all", "\n/autopilot", "\t/plan", "\ufeff/cwd /elsewhere", "/unknown-future-command", "/custom-skill"])("rejects native command dispatch %s", text => { expect(() => assertCopilotPromptPolicy(text)).toThrowError(expect.objectContaining({ code: "COPILOT_POLICY_VIOLATION", retryable: false })); }); + it.each(["tool_call", "tool_call_update"])("rejects native detach independent of tool name in %s", sessionUpdate => { + for (const tool of ["bash", "write_bash", "powershell", "unknown"]) { + expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate, tool, rawInput: { detach: true } })) + .toThrowError(expect.objectContaining({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED", retryable: false })); + expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate, tool, rawInput: { mode: "async", detach: false } })).not.toThrow(); + } + }); it("preserves ordinary prose mentioning commands and natural-language questions", () => { for (const text of ["Explain /plan", "Ask me before proceeding", "Read this file: /workspace/file", "Plan the work\n/yolo on"]) { expect(() => assertCopilotPromptPolicy(text)).not.toThrow(); @@ -75,6 +82,13 @@ describe("Copilot connection authority", () => { expect(() => guard("inbound", { method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "config_option_update", configOptions: safe.configOptions } } })).toThrow(); } }); + it("also denies detachment carried only by permission rawInput", () => { + const guard = createCopilotProtocolGuard("gpt-5.6-luna"); admit(guard); + expect(() => guard("inbound", { id: 0, method: "session/request_permission", params: { sessionId: "s", toolCall: { rawInput: { detach: true } } } })) + .toThrowError(expect.objectContaining({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED" })); + expect(() => guard("outbound", { id: 0, result: { outcome: { outcome: "selected", optionId: "allow_once" } } })) + .toThrowError(expect.objectContaining({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED" })); + }); it("does not turn native unresolved input into delivered answers or successful continuation", () => { for (const type of ["user_input.requested", "exit_plan_mode.requested", "elicitation.requested"]) { const guard = createCopilotProtocolGuard("gpt-5.6-luna"); admit(guard); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts index 51a892cd226..cfc467c5fd9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts @@ -2,7 +2,7 @@ export const COPILOT_AGENT_MODE = "https://agentclientprotocol.com/protocol/session-modes#agent"; export class CopilotPolicyViolationError extends Error { - readonly code = "COPILOT_POLICY_VIOLATION"; + readonly code: string = "COPILOT_POLICY_VIOLATION"; readonly retryable = false; constructor() { super("Copilot session configuration or command cannot preserve the admitted permission policy."); @@ -10,6 +10,15 @@ export class CopilotPolicyViolationError extends Error { } } +export class CopilotDetachedWorkUnsupportedError extends CopilotPolicyViolationError { + override readonly code = "COPILOT_DETACHED_WORK_UNSUPPORTED"; + constructor() { + super(); + this.name = "CopilotDetachedWorkUnsupportedError"; + this.message = "Native detached commands are unsupported in this Copilot runner. Run the command attached or use a managed runtime service."; + } +} + /** * Native ACP interprets a leading slash as a CLI command, before inference. * Reject every such command rather than maintaining an incomplete list of @@ -56,9 +65,15 @@ export function assertCopilotConfigPolicy(value: unknown): void { } } +/** Reject native detachment before permission handling, regardless of tool name. */ +export function assertCopilotToolPolicy(value: unknown): void { + if (record(record(value).rawInput).detach === true) throw new CopilotDetachedWorkUnsupportedError(); +} + /** Call synchronously for owned-session updates; any exception aborts authority. */ export function assertCopilotSessionUpdatePolicy(value: unknown): void { const update = record(value); + if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") assertCopilotToolPolicy(update); if (update.sessionUpdate === "current_mode_update" && update.currentModeId !== COPILOT_AGENT_MODE) { throw new CopilotPolicyViolationError(); } @@ -74,9 +89,9 @@ export function createCopilotProtocolGuard(expectedModel: string): (direction: " const options = record(result).configOptions; return Array.isArray(options) ? record(options.find(option => record(option).id === "model")).currentValue : undefined; }; - let failed = false; + let failure: CopilotPolicyViolationError | undefined; return (direction, value) => { - if (failed) throw new CopilotPolicyViolationError(); + if (failure) throw failure; try { const message = record(value); const params = record(message.params); @@ -118,6 +133,7 @@ export function createCopilotProtocolGuard(expectedModel: string): (direction: " } else if (request.method === "session/close" && request.sessionId) sessions.delete(request.sessionId); } } + if (direction === "inbound" && method === "session/request_permission") assertCopilotToolPolicy(params.toolCall); if (direction === "inbound" && method === "session/update") { // Replay can precede load's response. It still cannot introduce unsafe // configuration, and it cannot grant admission before that response. @@ -134,12 +150,12 @@ export function createCopilotProtocolGuard(expectedModel: string): (direction: " if (params.type === "session.permissions_changed" && data.mode !== "manual") throw new CopilotPolicyViolationError(); if (params.type === "session.mode_changed" && data.newMode !== "interactive" && data.newMode !== "agent") throw new CopilotPolicyViolationError(); } - } catch { - failed = true; + } catch (error) { + failure = error instanceof CopilotPolicyViolationError ? error : new CopilotPolicyViolationError(); sessions.clear(); models.clear(); pending.clear(); - throw new CopilotPolicyViolationError(); + throw failure; } }; } diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts index 2cc2bb36650..f2349dd5a1d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts @@ -61,4 +61,19 @@ describe("patched ACPX admission guard", () => { expect(c.kill).not.toHaveBeenCalled(); c.inbound.close(); }); + it("blocks detached command before the permission request can authorize a side effect", async () => { + const c = connection(); + await c.writer.write({ id: 0, method: "session/new", params: {} }); + c.inbound.enqueue({ id: 0, result: safe }); await c.reader.read(); + await c.writer.write({ id: 1, method: "session/prompt", params: { sessionId: "s", prompt: [{ type: "text", text: "Run task" }] } }); + const read = c.reader.read(); + c.inbound.enqueue({ method: "session/update", params: { sessionId: "s", update: { + sessionUpdate: "tool_call", toolCallId: "native-detached", kind: "execute", status: "pending", + rawInput: { command: "fixture", mode: "async", detach: true }, + } } }); + c.inbound.enqueue({ id: 0, method: "session/request_permission", params: { sessionId: "s", toolCall: { toolCallId: "native-detached" } } }); + await expect(read).rejects.toMatchObject({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED", message: expect.stringContaining("Run the command attached") }); + expect(c.written).toHaveLength(2); // Only session/new and session/prompt; no permission response. + expect(c.kill).toHaveBeenCalledWith("SIGTERM"); + }); }); diff --git a/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json b/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json index 13da8dc2b56..5cda9be4296 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json +++ b/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json @@ -169,9 +169,89 @@ "postTerminalObservationMs": 3000, "disposition": "provider_behavior_failure", "failedAssertion": "Detached shell must finish before prompt terminal result" + }, + { + "evidenceFile": "detached-shell-policy-denied.json", + "sha256": "a941e02818297025866b76f541a23d078515dcfd4a43bf26421ffd46025ed720", + "providerVersion": "1.0.88", + "scenario": "detached-shell", + "loopbackModelRequests": 1, + "permissionResponseCount": 0, + "markerAtPromptResult": false, + "markerAfterObservation": false, + "policyRejection": "COPILOT_DETACHED_WORK_UNSUPPORTED", + "disposition": "policy_denied_before_effect", + "scope": "exact repository tool-update policy source, not full production admission" + }, + { + "evidenceFile": "detached-shell-policy-denied-final.json", + "sha256": "4a2e91b561300ec6e2bad7ebb9d599ca502692883ffaa3a30d24d606f3c0284b", + "providerVersion": "1.0.88", + "scenario": "detached-shell", + "loopbackModelRequests": 1, + "permissionResponseCount": 0, + "markerAtPromptResult": false, + "markerAfterObservation": false, + "policyRejection": "COPILOT_DETACHED_WORK_UNSUPPORTED", + "disposition": "policy_denied_before_effect", + "scope": "exact repository tool-update policy source, not full production admission" + }, + { + "evidenceFile": "comparison-1.0.89-attached.json", + "sha256": "95c87df86e91483ac67d7f02ffd76cf2c968e4be5c0f79679c0a1e8f289d6b20", + "providerVersion": "1.0.89", + "scenario": "attached-shell", + "loopbackModelRequests": 3, + "permissionResponseCount": 1, + "markerAtPromptResult": true, + "markerAfterObservation": true, + "policyRejection": null, + "disposition": "passed", + "scope": "unguarded native protocol" + }, + { + "evidenceFile": "comparison-1.0.89-detached.json", + "sha256": "0dd99fa9fe72c831f09bebfa99d9effae16fe895964f9ef726462b92ca1cf0f3", + "providerVersion": "1.0.89", + "scenario": "detached-shell", + "loopbackModelRequests": 2, + "permissionResponseCount": 1, + "markerAtPromptResult": false, + "markerAfterObservation": true, + "policyRejection": null, + "disposition": "unsupported_native_lifetime", + "scope": "unguarded native protocol" } ], "emptySessionLoadDiagnosis": "Copilot does not retain an empty conversation for load. Initial resource-not-found retained; second attempt seeds one loopback-only text turn before close/load. It then requests shell permission and rejects the write.", "providerDeathDiagnosis": "One allowed seed mutation appends X. The exact provider is SIGKILLed and replaced against the same private state, then session/load succeeds. X occurs once after load and the next denied write remains absent. Request ID 0 is reused by the new process and requires fresh permission authority.", - "detachedSettlementDiagnosis": "Pinned Copilot returns end_turn and completed tool status for detach:true before the finite shell creates its marker. A separate diagnostic observes three seconds after terminal and proves the marker appears late. Empty background-task notices cannot prove task identity/count or settlement; no ACP task-list control is advertised. Retained failures are not converted into passes." + "detachedSettlementDiagnosis": "Explicit native detach returns end_turn before command completion in both 1.0.88 and isolated 1.0.89. Original failed settlement assertions are retained. This intentional native lifetime is unsupported by the runner, not proof that attached-shell upstream issue #4743 remains open. The production guard denies rawInput.detach:true before permission dispatch; native no-side-effect probe passes. Native Rust schemas/platform/feature variants and shell escape lifetimes remain outside comprehensive qualification.", + "comparisonRelease": { + "package": "@github/copilot-darwin-arm64", + "version": "1.0.89", + "tarball": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.89.tgz", + "integrity": "sha512-CaBNRZ1gRgatEdlgL2l/Ykw+79sOOYhBWFh3ohifYL7XgPBLf4xnXf59WlOTRT3Ojm4RSbbu/qVwBAa/elIdPQ==", + "tarballSha256": "7292cd2b3f0275e90bed9306bee23c5b7b1e22d604d8ea121f67c24f7192ad49", + "executableSha256": "97c12874d9adb9738374a9fb8a52cd724b81132ff815140f7e017bac706feba7" + }, + "toolSchemaEvidence": { + "evidenceFile": "native-tool-schemas.json", + "sha256": "68463a6afc5cf6b247998d76d4094fdc44789909197b2cd7a380b1649c8b4062", + "detachParameterTools": [ + "bash" + ], + "noDetachParameterTools": [ + "read_bash", + "stop_bash", + "task" + ], + "notAdvertised": [ + "write_bash", + "powershell", + "write_powershell", + "read_powershell" + ], + "comprehensiveAcrossPlatformsAndFlags": false + }, + "detachedPolicySourceSha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3" } From 860ac89c8635fde1f56bef4fb6c39ff64c2056d7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 09:58:50 -0500 Subject: [PATCH 5/5] Bind Copilot v3 identity to native detached-work admission Co-Authored-By: Paperclip --- .../paperclip-runner/scripts/build-copilot-distribution.mjs | 2 +- .../paperclip-runner/src/drivers/acpx/qualified-profiles.ts | 2 +- .../test/fixtures/copilot-profile-v3-identity.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs index 050c588934c..d50823f5ed4 100644 --- a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs +++ b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs @@ -7,7 +7,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; const MAX_EXPANDED_BYTES = 384 * 1024 * 1024; -const PROFILE_DIGEST = "sha256:4dbd3b0bead1dfcbe4b9b0d9a82a6660ae49c2b1a118862ec3ef654f3b76f8d7"; +const PROFILE_DIGEST = "sha256:31554905c7e37f44d9dd90b4858e6580271264ab2c871e6fd10fad194d00e374"; /** Build-time only; outputRoot is the exact runner-owned platform asset directory. */ export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) { diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index d333a9f8635..03af03ded97 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -74,7 +74,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 3, agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:4dbd3b0bead1dfcbe4b9b0d9a82a6660ae49c2b1a118862ec3ef654f3b76f8d7", + commandDigest: "sha256:31554905c7e37f44d9dd90b4858e6580271264ab2c871e6fd10fad194d00e374", // Authenticated discovery has not established a qualification model. Never // turn this empty declaration into a default; callers must select an ID. qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive", diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json index b14ef739fb5..5c5f4713575 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json @@ -1,5 +1,5 @@ { - "commandDigest": "sha256:4dbd3b0bead1dfcbe4b9b0d9a82a6660ae49c2b1a118862ec3ef654f3b76f8d7", + "commandDigest": "sha256:31554905c7e37f44d9dd90b4858e6580271264ab2c871e6fd10fad194d00e374", "declaration": { "schema": "paperclip.acpx_profile_declaration.v1", "agent": "copilot", @@ -8,7 +8,7 @@ "agentServerVersion": "1.0.88", "protocolPolicyRevision": "copilot-agent-manual-v1", "acpxPatchSha256": "cefbc196b0c46b5e34aa991d2265cb7466a955b473f07a2ba123fa44ca398579", - "policySha256": "48aec06d6232175b07a9ef917eb7df3042a1bc99cf4254a02aedd86c2ac68355", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", "distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f", "agentFilesBinding": "registered-runtime-context-v1" }