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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +51 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +266 -0
- data/docs/usage.md +91 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
- data/lib/ace/assign/authority/client.rb +107 -0
- data/lib/ace/assign/authority/deployment.rb +465 -0
- data/lib/ace/assign/authority/endcap.rb +342 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
- data/lib/ace/assign/authority/endcap_results.rb +380 -0
- data/lib/ace/assign/authority/endcap_services.rb +395 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
- data/lib/ace/assign/authority/launch_driver.rb +114 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
- data/lib/ace/assign/authority/posix_acl.rb +56 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +54 -0
- data/lib/ace/assign/authority/server.rb +230 -0
- data/lib/ace/assign/authority/service_evidence.rb +55 -0
- data/lib/ace/assign/authority/transfer_codec.rb +155 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/create.rb +9 -1
- data/lib/ace/assign/cli/commands/delivery.rb +57 -0
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
- data/lib/ace/assign/cli/commands/resume.rb +26 -0
- data/lib/ace/assign/cli.rb +22 -1
- data/lib/ace/assign/models/evidence_event.rb +3 -1
- data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
- data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +4 -1
- data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +13 -0
- 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 (`
|
|
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 (
|
|
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:
|
|
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:
|
|
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:
|
|
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-
|
|
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
|