ace-assign 0.63.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +51 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +4 -1
  61. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  62. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  63. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  64. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  65. data/lib/ace/assign/version.rb +1 -1
  66. data/lib/ace/assign.rb +13 -0
  67. metadata +119 -6
data/docs/usage.md CHANGED
@@ -101,7 +101,7 @@ ace-assign attempt reconcile --attempt ATTEMPT --receipt receipt.json
101
101
  - `attempt start` returns a JSON projection of the attempt (ID, state, binding facts, evidence references, digests). A repeated identical start returns the same attempt; a conflicting start exits 5 without launching a second writer.
102
102
  - `attempt status` exposes only state, binding, references, and digests -- never credentials or receipt bytes.
103
103
  - `attempt finish` accepts a structured receipt: operation, attributed producer, exact tested/reviewed head, verdict, artifact paths with SHA-256 digests, executed checks, and -- for review operations -- an executed independent reviewer verdict for that head.
104
- - `attempt reconcile` classifies interrupted attempts (`stopped` before process start, `uncertain` when an effect cannot be proven either way, still `running` only for verifiably live processes) and resolves uncertainty only against a verified receipt attributed to the recorded execution boundary. Merge, publish, and deploy effects are never replayed automatically.
104
+ - `attempt reconcile` classifies interrupted attempts (`uncertain` when launch or effect completion cannot be proven either way, still `running` only for verifiably live processes) and resolves uncertainty only against a verified receipt attributed to the recorded execution boundary. Merge, publish, and deploy effects are never replayed automatically.
105
105
 
106
106
  ### State machine
107
107
 
@@ -138,7 +138,14 @@ External service operations (driven through `ace-lab service request`) claim the
138
138
 
139
139
  - Task-attached (managed) attempts journal accepted evidence to the configured evidence Git ref (default `refs/ace/execution`) through an isolated, disposable audit checkout, outside the deliverable candidate branch. Journal commits never advance or exempt the reviewed candidate.
140
140
  - Taskless assignments persist attempts under `.ace-local/assign` with `recovery_mode: local_only`. This path never claims Git-backed recovery and cannot record external effects (`merge`, `publish`, `deploy`, `release`); attach the assignment to a source task before managed delivery.
141
- - Actor identity always comes from the execution boundary (OS login for local use, or the trusted service executor's identity). Unknown identity fails closed; flags never grant identity or authority.
141
+ - Actor identity always comes from the execution boundary (the kernel process account for local use, or the trusted service executor's identity). Local execution requires equal real and effective UIDs and resolves the effective UID's OS account; terminal login, `USER`, and `LOGNAME` do not select the actor. Unknown accounts, changing credentials, and unsupported privilege transitions fail closed; flags never grant identity or authority. Historical attempt ownership is never rewritten.
142
+
143
+ For developer callers, `Ace::Assign::Molecules::ExecutionIdentityResolver.new(adapter: "local").resolve.actor`
144
+ returns `Etc.getpwuid(Process.euid).name` for an ordinary process, including one without a terminal login.
145
+ If the OS account cannot be established or real/effective UIDs differ, it raises
146
+ `Ace::Assign::AttemptErrors::UnauthorizedIdentity` (CLI exit code 5). Correct the process launch/account;
147
+ setting `USER` or `LOGNAME` cannot authorize it. A previously recorded foreign owner still requires the
148
+ existing recovery process; this change does not transfer ownership.
142
149
 
143
150
  ### status integration
144
151
 
@@ -409,3 +416,85 @@ ace-assign select --clear
409
416
  | 130 | Interrupted (SIGINT) |
410
417
 
411
418
  See [exit-codes.md](exit-codes.md) for complete descriptions.
419
+
420
+ ## Resume accepted assignment state
421
+
422
+ ```bash
423
+ ace-assign resume --assignment ID --dry-run
424
+ ace-assign resume --assignment ID
425
+ ace-assign inbox-reconcile --attempt ID --event ID --receipt proof.json
426
+ ```
427
+
428
+ Both commands return JSON with task goal, checkpoint, attempts, pending HITL references and unresolved effect/inbox references. Each attempt reports `adopt`, `restart-required` or `reconcile-required`, liveness, recovery reason and its last verified observation. Dry-run writes nothing. Actual resume journals a bounded observation; unproven running attempts become uncertain and keep their writer slot. Intent-only reservations remain `reserved` with `reconcile-required`: absent process-start history never proves that a child did not launch. `attempt start` refuses to reuse an unbound reservation, and `attempt reconcile` reports that protected launch reconciliation requires positive no-execution/no-surviving-writer proof before abort. The protected launch authority owns that proof and abort path; these source primitives do not implement it. Finish also rejects an unbound reservation before accepting any receipt, even if a stale local cache says running. Imported canonical evidence stores the exact submitted bytes without Git clean filters or line-ending conversions; immutable reuse compares the accepted Git blob. It never launches a new process or resends a payload. Restart goes through a new `attempt start` only after the previous attempt ends with accepted evidence.
429
+
430
+ Managed agents invoking attempt start inside tmux/Herdr obtain a runtime-owned process binding. Recovery compares PID, UID, OS start time, host, exact pane/native session and Herdr terminal/native thread. A surviving shell is not the agent. Missing, unreadable or reused identity remains unknown. A plain local CLI establishes its actor through its effective UID's OS account, requiring equal real/effective UIDs, but supplies no durable agent identity; trusted service executors may supply `process_pid` in their configured identity JSON. Process observation never grants service privilege or effect authority.
431
+
432
+ Pending HITL references retain their exact scope/attempt and are not rebound by resume. Uncertain external claims require existing effect receipt reconciliation. Delivery consumption is separate from business success. Herdr owns signed inbox verification (`ace-herdr inbox reconcile --event ID --receipt FILE`); the assignment `inbox-reconcile` command uses that same configured verifier and additionally records the accepted observation references. `AttemptCoordinator#bind_inbox` records only the event reference, and `#reconcile_inbox` delegates that same verifier and journals only the verified proof digest/path and native observation reference. A consumed receipt settles transport once. A superseded receipt requeues the same event for an explicit retry, without succeeding the attempt. Exact signed replay recovers a crash between Herdr settlement and the assignment journal write; changed key, target, payload or generation fails closed. Retain the matching protected key pair for unresolved events or postpone rotation.
433
+
434
+ Installed Herdr/Pi compaction and writing-child stop/close acceptance must be recorded separately from deterministic fixture coverage before Lab cutover.
435
+
436
+ ### Attempt-bound forge delivery
437
+
438
+ `ace-assign create --task TASK --preset work-on-task --delivery-parameters delivery.json`
439
+ accepts an explicit delivery mapping. A local-only preset rejects that mapping.
440
+ The same mapping is retained as `delivery` in the assignment job:
441
+
442
+ ```json
443
+ {"forge_server":"lab-forge","pr_provenance":{"mode":"fork","head_repository_url":"https://forge.example/worker/project","head_ref":"task-branch","base_repository_url":"https://forge.example/team/project","base_ref":"main"}}
444
+ ```
445
+
446
+ Use either `forge_server` or boolean `forge_default: true`. With neither,
447
+ the first remote delivery resolves the repository remote and journals the selected
448
+ forge. Subsequent mutations retain that identity. Canonical mode requires equal
449
+ head and base repository URLs; fork mode names both repositories explicitly.
450
+ The source ref must already contain the exact local candidate commit.
451
+
452
+ `ace-assign delivery --assignment ID --attempt ATTEMPT --operation create --title TITLE`
453
+ creates a draft. Operations `update`, `review` and `status` reuse that attempt;
454
+ status and review do not append evidence. `ready --tests TEST-REF.json --review
455
+ REVIEW-REF.json` requires references containing `attempt_id` and `receipt_digest`
456
+ to coordinator-accepted, executed tests and independent approval at the current
457
+ candidate. Remote CI remains visible and advisory. An unresolved mutation is
458
+ reconciled by exact provider identity; absence or conflicting matches remain
459
+ unresolved and never trigger an automatic second write. Retrying readiness uses
460
+ the original intent's evidence references, revalidated against the current head.
461
+
462
+ Merge runs in the authorized qjx executor, which calls the neutral provider merge
463
+ primitive under its existing scoped policy. The worker only consumes completion:
464
+ `delivery --operation merge --service-request REQUEST --tests TEST-REF.json
465
+ --review REVIEW-REF.json`. A completed, verified receipt must bind this assignment,
466
+ attempt, candidate and exact PR resource; credentials do not grant authority.
467
+ Version and changelog preparation do not authorize publication.
468
+
469
+ The neutral `git/pr/create` and `git/pr/update` workflow and skill assets require
470
+ qkb.1 companion adoption across installed consumers. Their source tests do not
471
+ claim fresh installed workflow acceptance or authorized live merge acceptance.
472
+
473
+ Delivery context belongs to the assignment's existing evidence journal, while each
474
+ operation belongs to its own scoped attempt. Closing create step `030` and starting
475
+ update `147` or readiness `148` retains the exact PR and forge. An unresolved intent
476
+ from any earlier scoped attempt prevents a second mutation: reconciliation records
477
+ a result under the current attempt and links the original intent digest and attempt.
478
+ Status history includes each event's owning `attempt_id` and `event_digest`. No
479
+ single delivery attempt needs to remain active throughout the recipe.
480
+ If reconciliation recovers a different operation than the requested step (for
481
+ example, create while invoking readiness), the command reports that the requested
482
+ step still needs execution. It does not return success for an unperformed step.
483
+ Retry that requested operation with its current evidence after recovery settles.
484
+
485
+ Recovery compares the exact requested effect, not only its operation name. An
486
+ uncertain update of one title/body cannot complete a later update with different
487
+ content. Creation also checks the requested title/body against the observed draft,
488
+ including adoption of an already known draft. A mismatched request receives
489
+ `AttemptErrors::CurrentEffectRequired` after the old intent is settled; the new
490
+ effect remains unperformed until explicitly retried. Explicit readiness references
491
+ must match the recovered intent; omitting them during retry retains and revalidates
492
+ its original references. Merge always consumes the exact named qjx service request,
493
+ its candidate/target binding and verified receipt, never an older PR result.
494
+
495
+
496
+ ## Protected cross-user native launch
497
+
498
+ The fixed installed authority exposes `ace-assign authority serve`, `launch`,
499
+ `status` and `terminate`. See [protected authority](protected-authority.md) for
500
+ OS policy, native artifact installation, canonical launch stages and safe replay.
@@ -924,10 +924,10 @@ When executing a step with a `skill:` field:
924
924
  |-------|-----------|---------|
925
925
  | `onboard` | `/as-onboard` | Load project context |
926
926
  | `ace:task-work` | `/as-task-work <taskref>` | Implement task changes |
927
- | `ace:github-pr-create` | `/as-github-pr-create` | Create pull request |
927
+ | `ace:git-pr-create` | `/as-git-pr-create` | Create pull request |
928
928
  | `ace:review-pr` | `/as-review-pr [pr#]` | Review code changes |
929
929
  | `ace:git-commit` | `/as-git-commit` | Generate commit message |
930
- | `ace:github-pr-update` | `/as-github-pr-update` | Update PR description |
930
+ | `ace:git-pr-update` | `/as-git-pr-update` | Update PR description |
931
931
 
932
932
  ## Error Handling
933
933
 
@@ -168,7 +168,7 @@ When parsing informal instructions, identify:
168
168
  1. **Task References**: "task 123", "task-123", "#123"
169
169
  2. **Skill Keywords**: Map to known skills
170
170
  - "work on task" → `ace:task-work`
171
- - "create pr", "make pr" → `ace:github-pr-create`
171
+ - "create pr", "make pr" → `ace:git-pr-create`
172
172
  - "review", "review pr" → `ace:review-pr`
173
173
  - "commit" → `ace:git-commit`
174
174
  - "onboard" → `onboard`
@@ -379,7 +379,7 @@ steps:
379
379
  - Implement the required changes following project conventions.
380
380
 
381
381
  - name: create-pr
382
- source: skill://as-github-pr-create
382
+ source: skill://as-git-pr-create
383
383
  instructions:
384
384
  - Create a pull request for the changes.
385
385
  - Capture the PR number for subsequent review steps.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+ require "ace/git/atoms/server_url"
3
+
4
+ module Ace
5
+ module Assign
6
+ module Atoms
7
+ # Explicit non-secret remote delivery inputs; local recipes need not call this validator.
8
+ module DeliveryParameters
9
+ PROVENANCE_FIELDS = %w[mode head_repository_url head_ref base_repository_url base_ref].freeze
10
+ def self.validate(input)
11
+ raise ArgumentError, "Delivery parameters must be a mapping" unless input.is_a?(Hash)
12
+ unless (input.keys - %w[forge_server forge_default pr_provenance]).empty?
13
+ raise ArgumentError, "Unexpected delivery parameter fields"
14
+ end
15
+ default = input.fetch("forge_default", false)
16
+ raise ArgumentError, "forge_default must be boolean" unless [true, false].include?(default)
17
+ server = input["forge_server"]
18
+ if server && (!server.is_a?(String) || !server.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/))
19
+ raise ArgumentError, "Invalid forge_server"
20
+ end
21
+ raise ArgumentError, "forge_server and forge_default are mutually exclusive" if server && default
22
+ provenance = input["pr_provenance"]
23
+ unless provenance.is_a?(Hash) && provenance.keys.sort == PROVENANCE_FIELDS.sort &&
24
+ %w[fork canonical].include?(provenance["mode"])
25
+ raise ArgumentError, "Explicit complete fork or canonical PR provenance is required"
26
+ end
27
+ %w[head_repository_url base_repository_url].each { |key| validate_url!(provenance[key]) }
28
+ %w[head_ref base_ref].each do |key|
29
+ ref = provenance[key]
30
+ unless ref.is_a?(String) && ref.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_\/.\-]{0,255}\z/) &&
31
+ !ref.include?("..") && !ref.include?("//") && !ref.end_with?("/", ".", ".lock")
32
+ raise ArgumentError, "Invalid PR ref"
33
+ end
34
+ end
35
+ if provenance["mode"] == "canonical" && !Ace::Git::Atoms::ServerUrl.match?(
36
+ provenance["head_repository_url"], provenance["base_repository_url"])
37
+ raise ArgumentError, "Canonical provenance requires matching head and base repositories"
38
+ end
39
+ {"forge_server" => server, "forge_default" => default,
40
+ "pr_provenance" => provenance.transform_values(&:dup)}
41
+ end
42
+
43
+ def self.validate_url!(url)
44
+ unless url.is_a?(String) && url.length <= 512 &&
45
+ (url.match?(%r{\Ahttps?://[a-zA-Z0-9.:-]+/[a-zA-Z0-9_./-]+\z}) ||
46
+ url.match?(%r{\Agit@[a-zA-Z0-9.-]+:[a-zA-Z0-9_./-]+\z}) ||
47
+ url.match?(%r{\Assh://git@[a-zA-Z0-9.:-]+/[a-zA-Z0-9_./-]+\z}))
48
+ raise ArgumentError, "PR repository URL must contain a repository and no credentials or query"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+ require "ace/herdr"
7
+ require_relative "private_directory"
8
+
9
+ module Ace
10
+ module Assign
11
+ module Authority
12
+ # Untrusted bundles enter a clean bare quarantine. Only complete verified
13
+ # objects are retained; no uploader configuration or worktree is read.
14
+ class CandidateTransfer
15
+ MAX_BYTES = 64 * 1024 * 1024
16
+ DEADLINE = 30
17
+ SHA = /\A[0-9a-f]{40}\z/
18
+
19
+ def initialize(root:)
20
+ @root = File.expand_path(root)
21
+ verify_root!
22
+ end
23
+
24
+ def admit(bytes:, sha256:, size:, head:, &consumer)
25
+ unless bytes.is_a?(String) && size.is_a?(Integer) && size.positive? && size <= MAX_BYTES &&
26
+ size == bytes.bytesize && sha256.is_a?(String) &&
27
+ Digest::SHA256.hexdigest(bytes.b) == sha256 && head.is_a?(String) && head.match?(SHA)
28
+ reject!("Candidate bundle binding or size is invalid")
29
+ end
30
+ verify_root!
31
+ deadline = monotonic + DEADLINE
32
+ Dir.mktmpdir("quarantine-", @root) do |directory|
33
+ File.chmod(0700, directory)
34
+ bundle = File.join(directory, "candidate.bundle")
35
+ File.open(bundle, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) { |f| f.write(bytes.b) }
36
+ repository = File.join(directory, "repository")
37
+ git(directory, deadline, "init", "--bare", "--template=", repository)
38
+ # Verification against an empty repository rejects incremental
39
+ # bundles requiring any caller-supplied prerequisites.
40
+ git(repository, deadline, "bundle", "verify", bundle)
41
+ advertised = git(repository, deadline, "bundle", "list-heads", bundle).lines.map { |line| line.split.first }
42
+ reject!("Candidate head is not advertised") unless advertised.include?(head)
43
+ git(repository, deadline, "fetch", "--no-tags", bundle, "#{head}:refs/heads/candidate")
44
+ actual = git(repository, deadline, "rev-parse", "refs/heads/candidate^{commit}").strip
45
+ reject!("Candidate commit differs from declared head") unless actual == head
46
+ git(repository, deadline, "fsck", "--full", "--strict", "--no-reflogs")
47
+ tree = git(repository, deadline, "rev-parse", "#{head}^{tree}").strip
48
+ exported = File.join(directory, "verified.bundle")
49
+ git(repository, deadline, "bundle", "create", exported, "refs/heads/candidate")
50
+ verified = read_bundle(exported)
51
+ # The journal owns candidate identity/generation. This object is
52
+ # only immutable transport bytes, not another authority ledger.
53
+ result = {"head" => head, "tree" => tree, "bundle" => verified,
54
+ "sha256" => Digest::SHA256.hexdigest(verified), "bytes" => verified.bytesize}
55
+ consumer ? consumer.call(repository, deadline, result) : result
56
+ end
57
+ rescue Timeout::Error
58
+ reject!("Candidate transfer deadline exceeded")
59
+ rescue SystemCallError
60
+ reject!("Candidate transfer filesystem is unavailable")
61
+ end
62
+
63
+ # root is selected from installed peer credentials by the receiver,
64
+ # never a request field. Materialization reads objects rather than Git
65
+ # checkout, so .gitattributes, filters and uploader hooks cannot run.
66
+ def materialize(bytes:, sha256:, size:, head:, tree:, root:)
67
+ self.class.new(root: root)
68
+ admit(bytes: bytes, sha256: sha256, size: size, head: head) do |repository, deadline, result|
69
+ reject!("Candidate tree differs from accepted tree") unless result["tree"] == tree
70
+ destination = Dir.mktmpdir("candidate-", root)
71
+ File.chmod(0700, destination)
72
+ begin
73
+ entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).split("\0")
74
+ total = 0
75
+ links = []
76
+ entries.each do |entry|
77
+ metadata, path = entry.split("\t", 2)
78
+ mode, kind, oid = metadata.to_s.split(" ")
79
+ parts = path.to_s.split("/", -1)
80
+ unless path && !path.start_with?("/") &&
81
+ parts.none? { |part| part.empty? || %w[. .. .git].include?(part.downcase) } &&
82
+ kind == "blob" && %w[100644 100755 120000].include?(mode) && oid.match?(SHA)
83
+ reject!("Candidate contains unsafe filesystem entry")
84
+ end
85
+ content = git(repository, deadline, "cat-file", "blob", oid, limit: MAX_BYTES).b
86
+ total += content.bytesize
87
+ reject!("Candidate expanded content is oversized") if total > MAX_BYTES
88
+ target = File.join(destination, path)
89
+ FileUtils.mkdir_p(File.dirname(target), mode: 0700)
90
+ if mode == "120000"
91
+ resolved = File.expand_path(content, File.dirname(target))
92
+ unless !content.include?("\0") && !content.start_with?("/") &&
93
+ resolved.start_with?(destination + "/")
94
+ reject!("Candidate symlink escapes private checkout")
95
+ end
96
+ links << [target, content]
97
+ else
98
+ File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW,
99
+ mode == "100755" ? 0700 : 0600) { |file| file.write(content) }
100
+ end
101
+ end
102
+ links.each { |target, content| File.symlink(content, target) }
103
+ links.each do |target, _content|
104
+ resolved = File.realpath(target)
105
+ reject!("Candidate symlink escapes private checkout") unless resolved.start_with?(destination + "/")
106
+ end
107
+ result.reject { |key, _value| key == "bundle" }.merge("directory" => destination)
108
+ rescue Exception
109
+ # Only this freshly created private disposable directory is
110
+ # removed. An existing peer scratch tree is never cleaned.
111
+ FileUtils.rm_rf(destination)
112
+ raise
113
+ end
114
+ end
115
+ rescue SystemCallError, ArgumentError
116
+ reject!("Candidate filesystem materialization is unverifiable")
117
+ end
118
+
119
+ private
120
+
121
+ def verify_root!
122
+ PrivateDirectory.verify!(@root)
123
+ end
124
+
125
+ def git(repository, deadline, *arguments, limit: 65_536)
126
+ remaining = deadline - monotonic
127
+ raise Timeout::Error if remaining <= 0
128
+ # Git variables may redirect object/config storage even in a clean
129
+ # repository. Strip every inherited Git setting and global config.
130
+ environment = ENV.keys.to_h { |key| [key, nil] }
131
+ environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => "/dev/null",
132
+ "GIT_TERMINAL_PROMPT" => "0", "GIT_ALLOW_PROTOCOL" => "file",
133
+ "GIT_NO_REPLACE_OBJECTS" => "1", "PATH" => "/usr/bin:/bin", "LC_ALL" => "C")
134
+ result = Herdr::Molecules::BoundedProcess.call([environment, "/usr/bin/git", "-C", repository,
135
+ "-c", "core.hooksPath=/dev/null", "-c", "protocol.file.allow=always", *arguments],
136
+ stdin_data: "", timeout_s: remaining, output_limit: limit)
137
+ reject!("Candidate Git verification failed") unless result.status.success? && !result.oversized
138
+ result.stdout
139
+ end
140
+
141
+ def read_bundle(path)
142
+ File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
143
+ reject!("Verified bundle is oversized") unless file.stat.file? && file.stat.size <= MAX_BYTES
144
+ bytes = file.read(MAX_BYTES + 1).b
145
+ reject!("Verified bundle is oversized") if bytes.bytesize > MAX_BYTES
146
+ bytes
147
+ end
148
+ end
149
+
150
+ def monotonic
151
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
152
+ end
153
+
154
+ def reject!(message)
155
+ raise AttemptErrors::ReceiptRejected, message
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+ require "securerandom"
3
+ require_relative "deployment"
4
+ require_relative "transfer_codec"
5
+ require_relative "../molecules/canonical_evidence"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Authority
10
+ class Client
11
+ Reply = Struct.new(:data, :replayed, :parts, keyword_init: true)
12
+ attr_reader :mapping_id
13
+ def initialize(mapping_id:, deployment: Deployment.load, kernel: Ace::Runtime::Molecules::ProtectedLinux.new)
14
+ @mapping_id, @deployment, @kernel = mapping_id, deployment, kernel
15
+ @map = deployment.mapping(mapping_id)
16
+ @service = deployment.authority(@map.fetch("authority_id"))
17
+ end
18
+
19
+ def call(operation, params, mutation_id: nil, timeout: nil, upload_parts: nil, download: false, purpose: nil)
20
+ if operation == "evidence_fetch" && (!download || purpose != :artifacts || upload_parts)
21
+ raise ArgumentError, "evidence fetch requires fixed artifacts download"
22
+ end
23
+ if operation == "submit_result" && (!upload_parts || purpose != :receipt_artifacts || download)
24
+ raise ArgumentError, "result submission requires fixed receipt upload"
25
+ end
26
+ @deployment.verify!(mapping_id, kernel: @kernel)
27
+ wire = Ace::Runtime::Molecules::ProtectedSocket
28
+ path = @service.fetch("socket_path")
29
+ before = wire.socket_identity(path)
30
+ raise Ace::Runtime::RuntimeUnavailableError, "authority endpoint owner differs" unless before.last == @service.fetch("uid")
31
+ raise ArgumentError, "cannot upload and download on one request" if upload_parts && download
32
+ codec = transfer_codec if upload_parts || download
33
+ descriptor = codec.descriptor(upload_parts, purpose: purpose) if upload_parts
34
+ parameters = upload_parts ? params.merge("transfer" => descriptor) : params
35
+ cap = %w[reserve_attempt close_execution_scope].include?(operation) ? 90 : 30
36
+ duration = timeout || (cap == 90 ? 90 : 5)
37
+ unless duration.is_a?(Numeric) && duration.positive? && duration.finite?
38
+ raise ArgumentError, "authority timeout must be finite and positive"
39
+ end
40
+ deadline = wire.deadline([duration, cap].min)
41
+ wire.connect(path, deadline: deadline) do |socket|
42
+ peer = @kernel.peer(socket)
43
+ unless peer["uid"] == @service.fetch("uid") && peer["gid"] == @service.fetch("gid") &&
44
+ peer["groups"] == @service.fetch("groups") && wire.socket_identity(path) == before
45
+ raise AttemptErrors::UnauthorizedIdentity, "authority endpoint peer changed"
46
+ end
47
+ wire.write(socket, {"version" => 1, "operation" => operation,
48
+ "mutation_id" => mutation_id, "project_id" => @map.fetch("project_id"),
49
+ "params" => parameters.merge("mapping_id" => mapping_id)}, deadline: deadline, limit: upload_parts || download ? 16_384 : wire::LIMIT)
50
+ if %w[evidence_fetch observe_execution_scope close_execution_scope].include?(operation) || (operation == "attempt_status" && params.key?("result_candidate_generation"))
51
+ socket.shutdown(Socket::SHUT_WR)
52
+ end
53
+ if upload_parts
54
+ codec.send(socket, parts: upload_parts, descriptor: descriptor, purpose: purpose, deadline: deadline)
55
+ socket.shutdown(Socket::SHUT_WR)
56
+ end
57
+ result = wire.read(socket, deadline: deadline, limit: 16_384)
58
+ unless result.is_a?(Hash) && result["status"] == "ok" && result["data"].is_a?(Hash)
59
+ code = result.is_a?(Hash) ? result.dig("error", "code") : nil
60
+ raise AttemptErrors::EvidenceUnavailable, "protected authority refused (#{code || 'invalid_response'})"
61
+ end
62
+ transport = result.fetch("transport")
63
+ unless transport.is_a?(Hash) && transport.keys == ["replayed"] && [true, false].include?(transport["replayed"])
64
+ raise AttemptErrors::EvidenceUnavailable, "authority replay metadata is unavailable"
65
+ end
66
+ validate_evidence_download!(result.fetch("data"), params) if operation == "evidence_fetch"
67
+ parts = if download
68
+ codec.receive(socket, descriptor: result.fetch("data").fetch("transfer"), purpose: purpose, deadline: deadline) do |input|
69
+ Array.new(input.count) { |index| input.bytes(index: index) }
70
+ end
71
+ end
72
+ Reply.new(data: result.fetch("data"), replayed: transport.fetch("replayed"), parts: parts)
73
+ end
74
+ rescue SystemCallError, IOError
75
+ raise AttemptErrors::EvidenceUnavailable, "protected authority connection unavailable; no local fallback"
76
+ end
77
+ private
78
+
79
+ def validate_evidence_download!(data, params)
80
+ descriptor, transfer = data.values_at("descriptor", "transfer")
81
+ unless data.keys.sort == %w[descriptor generation journal_commit transfer] &&
82
+ Molecules::CanonicalEvidence.valid_descriptor?(descriptor) &&
83
+ descriptor["project_id"] == @map.fetch("project_id") &&
84
+ {"assignment_id" => "assignment_id", "attempt_id" => "attempt_id", "kind" => "kind",
85
+ "request_id_or_event_id" => "purpose_id", "artifact_id" => "artifact_id"}.all? { |field, key| descriptor[field] == params[key] } &&
86
+ data["generation"].is_a?(Integer) && data["generation"] >= 0 &&
87
+ data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A[0-9a-f]{40,64}\z/) &&
88
+ transfer.is_a?(Hash) && transfer.keys.sort == %w[bytes parts sha256 version] && transfer["version"] == 1 &&
89
+ transfer["bytes"] == descriptor["bytes"] && transfer["sha256"] == descriptor["sha256"] &&
90
+ transfer["parts"] == [descriptor.slice("bytes", "sha256")]
91
+ raise AttemptErrors::EvidenceUnavailable, "canonical download descriptor differs"
92
+ end
93
+ end
94
+
95
+ def transfer_codec
96
+ peer = @kernel.capture(Process.pid)
97
+ project = @deployment.project(@map.fetch("project_id"))
98
+ installed = project.fetch("peer_credentials").fetch(peer.fetch("uid").to_s)
99
+ unless peer["gid"] == installed["gid"] && peer["groups"] == installed["groups"]
100
+ raise AttemptErrors::UnauthorizedIdentity, "transfer scratch principal differs"
101
+ end
102
+ TransferCodec.new(root: installed.fetch("scratch_root"))
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end